package database import ( "context" "database/sql" "database/sql/driver" "encoding/json" "log" "time" "github.com/google/uuid" "go.b0esche.cloud/backend/internal/models" ) type DB struct { *sql.DB } func New(db *sql.DB) *DB { return &DB{DB: db} } // StringArray handles nullable string arrays from PostgreSQL type StringArray []string // Scan handles NULL values properly func (sa *StringArray) Scan(value interface{}) error { if value == nil { *sa = StringArray{} return nil } // Handle byte slice from PostgreSQL array if bytes, ok := value.([]byte); ok { var arr []string if err := json.Unmarshal(bytes, &arr); err != nil { // If JSON parse fails, try as raw string *sa = StringArray{string(bytes)} return nil } *sa = StringArray(arr) return nil } // Handle string directly if str, ok := value.(string); ok { if str == "" { *sa = StringArray{} return nil } *sa = StringArray{str} return nil } return nil } // Value implements the driver.Valuer interface func (sa StringArray) Value() (driver.Value, error) { if len(sa) == 0 { return nil, nil } return json.Marshal(sa) } type User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` Username string `json:"username"` DisplayName string `json:"displayName"` PasswordHash *string `json:"-"` CreatedAt time.Time `json:"createdAt"` LastLoginAt *time.Time `json:"lastLoginAt"` } type Credential struct { ID string UserID uuid.UUID CredentialPublicKey []byte CredentialID []byte SignCount int64 CreatedAt time.Time LastUsedAt *time.Time Transports StringArray } type AuthChallenge struct { ID uuid.UUID UserID uuid.UUID Challenge []byte ChallengeType string CreatedAt time.Time ExpiresAt time.Time UsedAt *time.Time } type Session struct { ID uuid.UUID UserID uuid.UUID ExpiresAt time.Time RevokedAt *time.Time } type Organization struct { ID uuid.UUID `json:"id"` OwnerID uuid.UUID `json:"ownerId"` Name string `json:"name"` Slug string `json:"slug"` InviteLinkToken *string `json:"inviteLinkToken,omitempty"` CreatedAt time.Time `json:"createdAt"` } type Membership struct { UserID uuid.UUID OrgID uuid.UUID Role string CreatedAt time.Time } type Invitation struct { ID uuid.UUID `json:"id"` OrgID uuid.UUID `json:"orgId"` InvitedBy uuid.UUID `json:"invitedBy"` Username string `json:"username"` Role string `json:"role"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` AcceptedAt *time.Time `json:"acceptedAt"` } type JoinRequest struct { ID uuid.UUID OrgID uuid.UUID UserID uuid.UUID InviteToken *string RequestedAt time.Time Status string } type Activity struct { ID uuid.UUID UserID uuid.UUID OrgID uuid.UUID FileID *string Action string Metadata map[string]interface{} Timestamp time.Time } type File struct { ID uuid.UUID OrgID *uuid.UUID UserID *uuid.UUID Name string Path string Type string Size int64 LastModified time.Time CreatedAt time.Time ModifiedBy *uuid.UUID ModifiedByName string } func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { var user User err := db.QueryRowContext(ctx, ` INSERT INTO users (id, email, display_name) VALUES (gen_random_uuid(), $1, $2) ON CONFLICT (email) DO UPDATE SET display_name = EXCLUDED.display_name, last_login_at = NOW() RETURNING id, email, display_name, created_at, last_login_at `, email, name).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.LastLoginAt) if err != nil { return nil, err } return &user, nil } func (db *DB) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time) (*Session, error) { var session Session err := db.QueryRowContext(ctx, ` INSERT INTO sessions (user_id, expires_at) VALUES ($1, $2) RETURNING id, user_id, expires_at, revoked_at `, userID, expiresAt).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) if err != nil { return nil, err } return &session, nil } func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, error) { var session Session err := db.QueryRowContext(ctx, ` SELECT id, user_id, expires_at, revoked_at FROM sessions WHERE id = $1 `, sessionID).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) if err != nil { return nil, err } return &session, nil } func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE sessions SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL `, sessionID) return err } func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) { rows, err := db.QueryContext(ctx, ` SELECT o.id, o.owner_id, o.name, o.slug, o.created_at FROM organizations o JOIN memberships m ON o.id = m.org_id WHERE m.user_id = $1 `, userID) if err != nil { return nil, err } defer rows.Close() var orgs []Organization for rows.Next() { var org Organization if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil { return nil, err } orgs = append(orgs, org) } return orgs, rows.Err() } func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*Membership, error) { var membership Membership err := db.QueryRowContext(ctx, ` SELECT user_id, org_id, role, created_at FROM memberships WHERE user_id = $1 AND org_id = $2 `, userID, orgID).Scan(&membership.UserID, &membership.OrgID, &membership.Role, &membership.CreatedAt) if err != nil { return nil, err } return &membership, nil } // GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) { return db.GetUserMembership(ctx, userID, orgID) } func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) { // Generate a unique invite link token inviteToken := uuid.New().String() var org Organization err := db.QueryRowContext(ctx, ` INSERT INTO organizations (owner_id, name, slug, invite_link_token) VALUES ($1, $2, $3, $4) RETURNING id, owner_id, name, slug, invite_link_token, created_at `, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt) if err != nil { return nil, err } return &org, nil } func (db *DB) AddMembership(ctx context.Context, userID, orgID uuid.UUID, role string) error { _, err := db.ExecContext(ctx, ` INSERT INTO memberships (user_id, org_id, role) VALUES ($1, $2, $3) `, userID, orgID, role) return err } func (db *DB) LogActivity(ctx context.Context, userID, orgID uuid.UUID, fileID *string, action string, metadata map[string]interface{}) error { _, err := db.ExecContext(ctx, ` INSERT INTO activities (user_id, org_id, file_id, action, metadata) VALUES ($1, $2, $3, $4, $5) `, userID, orgID, fileID, action, metadata) return err } func (db *DB) GetOrgActivities(ctx context.Context, orgID uuid.UUID, limit int) ([]Activity, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, org_id, file_id, action, metadata, timestamp FROM activities WHERE org_id = $1 ORDER BY timestamp DESC LIMIT $2 `, orgID, limit) if err != nil { return nil, err } defer rows.Close() var activities []Activity for rows.Next() { var a Activity err := rows.Scan(&a.ID, &a.UserID, &a.OrgID, &a.FileID, &a.Action, &a.Metadata, &a.Timestamp) if err != nil { return nil, err } activities = append(activities, a) } return activities, rows.Err() } func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, error) { rows, err := db.QueryContext(ctx, ` SELECT user_id, org_id, role, created_at FROM memberships WHERE org_id = $1 `, orgID) if err != nil { return nil, err } defer rows.Close() var memberships []Membership for rows.Next() { var m Membership err := rows.Scan(&m.UserID, &m.OrgID, &m.Role, &m.CreatedAt) if err != nil { return nil, err } memberships = append(memberships, m) } return memberships, rows.Err() } // GetOrgMembersWithUsers returns members with user details func (db *DB) GetOrgMembersWithUsers(ctx context.Context, orgID uuid.UUID) ([]struct { Membership User }, error) { rows, err := db.QueryContext(ctx, ` SELECT m.user_id, m.org_id, m.role, m.created_at, u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at FROM memberships m JOIN users u ON m.user_id = u.id WHERE m.org_id = $1 ORDER BY m.created_at `, orgID) if err != nil { return nil, err } defer rows.Close() members := make([]struct { Membership User }, 0) for rows.Next() { var m struct { Membership User } err := rows.Scan( &m.Membership.UserID, &m.Membership.OrgID, &m.Membership.Role, &m.Membership.CreatedAt, &m.User.ID, &m.User.Email, &m.User.Username, &m.User.DisplayName, &m.User.CreatedAt, &m.User.LastLoginAt, ) if err != nil { return nil, err } members = append(members, m) } return members, rows.Err() } // UpdateMemberRole updates a member's role func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, newRole string) error { _, err := db.ExecContext(ctx, ` UPDATE memberships SET role = $1 WHERE org_id = $2 AND user_id = $3 `, newRole, orgID, userID) return err } // RemoveMember removes a user from an organization func (db *DB) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error { _, err := db.ExecContext(ctx, ` DELETE FROM memberships WHERE org_id = $1 AND user_id = $2 `, orgID, userID) return err } // SearchUsersByUsername searches users by partial username match func (db *DB) SearchUsersByUsername(ctx context.Context, query string, limit int) ([]User, error) { if limit <= 0 { limit = 10 } rows, err := db.QueryContext(ctx, ` SELECT id, email, username, display_name, created_at, last_login_at FROM users WHERE username ILIKE $1 ORDER BY username LIMIT $2 `, "%"+query+"%", limit) if err != nil { return nil, err } defer rows.Close() users := make([]User, 0) for rows.Next() { var u User err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.DisplayName, &u.CreatedAt, &u.LastLoginAt) if err != nil { return nil, err } users = append(users, u) } return users, rows.Err() } // CreateInvitation creates a new invitation func (db *DB) CreateInvitation(ctx context.Context, orgID, invitedBy uuid.UUID, username, role string) (*Invitation, error) { var inv Invitation err := db.QueryRowContext(ctx, ` INSERT INTO invitations (org_id, invited_by, username, role) VALUES ($1, $2, $3, $4) RETURNING id, org_id, invited_by, username, role, created_at, expires_at, accepted_at `, orgID, invitedBy, username, role).Scan( &inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role, &inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt, ) if err != nil { return nil, err } return &inv, nil } // GetOrgInvitations returns pending invitations for an org func (db *DB) GetOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Invitation, error) { rows, err := db.QueryContext(ctx, ` SELECT id, org_id, invited_by, username, role, created_at, expires_at, accepted_at FROM invitations WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC `, orgID) if err != nil { return nil, err } defer rows.Close() invitations := make([]Invitation, 0) for rows.Next() { var inv Invitation err := rows.Scan( &inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role, &inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt, ) if err != nil { return nil, err } invitations = append(invitations, inv) } return invitations, rows.Err() } // CancelInvitation cancels an invitation func (db *DB) CancelInvitation(ctx context.Context, invitationID uuid.UUID) error { _, err := db.ExecContext(ctx, ` DELETE FROM invitations WHERE id = $1 `, invitationID) return err } // CreateJoinRequest creates a join request func (db *DB) CreateJoinRequest(ctx context.Context, orgID, userID uuid.UUID, inviteToken *string) (*JoinRequest, error) { var req JoinRequest err := db.QueryRowContext(ctx, ` INSERT INTO join_requests (org_id, user_id, invite_token) VALUES ($1, $2, $3) ON CONFLICT (org_id, user_id) DO UPDATE SET invite_token = EXCLUDED.invite_token, requested_at = NOW(), status = 'pending' RETURNING id, org_id, user_id, invite_token, requested_at, status `, orgID, userID, inviteToken).Scan( &req.ID, &req.OrgID, &req.UserID, &req.InviteToken, &req.RequestedAt, &req.Status, ) if err != nil { return nil, err } return &req, nil } // GetOrgJoinRequests returns pending join requests for an org func (db *DB) GetOrgJoinRequests(ctx context.Context, orgID uuid.UUID) ([]struct { JoinRequest User }, error) { rows, err := db.QueryContext(ctx, ` SELECT jr.id, jr.org_id, jr.user_id, jr.invite_token, jr.requested_at, jr.status, u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at FROM join_requests jr JOIN users u ON jr.user_id = u.id WHERE jr.org_id = $1 AND jr.status = 'pending' ORDER BY jr.requested_at DESC `, orgID) if err != nil { return nil, err } defer rows.Close() requests := make([]struct { JoinRequest User }, 0) for rows.Next() { var r struct { JoinRequest User } err := rows.Scan( &r.JoinRequest.ID, &r.JoinRequest.OrgID, &r.JoinRequest.UserID, &r.JoinRequest.InviteToken, &r.JoinRequest.RequestedAt, &r.JoinRequest.Status, &r.User.ID, &r.User.Email, &r.User.Username, &r.User.DisplayName, &r.User.CreatedAt, &r.User.LastLoginAt, ) if err != nil { return nil, err } requests = append(requests, r) } return requests, rows.Err() } // AcceptJoinRequest accepts a join request and adds the user as member func (db *DB) AcceptJoinRequest(ctx context.Context, requestID uuid.UUID, role string) error { // Get the request details var orgID, userID uuid.UUID err := db.QueryRowContext(ctx, ` SELECT org_id, user_id FROM join_requests WHERE id = $1 AND status = 'pending' `, requestID).Scan(&orgID, &userID) if err != nil { return err } // Add membership err = db.AddMembership(ctx, userID, orgID, role) if err != nil { return err } // Mark request as accepted _, err = db.ExecContext(ctx, ` UPDATE join_requests SET status = 'accepted' WHERE id = $1 `, requestID) return err } // RejectJoinRequest rejects a join request func (db *DB) RejectJoinRequest(ctx context.Context, requestID uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE join_requests SET status = 'rejected' WHERE id = $1 `, requestID) return err } // GetInviteLink returns the invite link token for an org func (db *DB) GetInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) { var token *string err := db.QueryRowContext(ctx, ` SELECT invite_link_token FROM organizations WHERE id = $1 `, orgID).Scan(&token) if err != nil { return nil, err } return token, nil } // RegenerateInviteLink generates a new invite link token func (db *DB) RegenerateInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) { newToken := uuid.New().String() _, err := db.ExecContext(ctx, ` UPDATE organizations SET invite_link_token = $1 WHERE id = $2 `, newToken, orgID) if err != nil { return nil, err } return &newToken, nil } // GetOrgFiles returns files for a given organization (top-level folder listing) func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 100 } offset := (page - 1) * pageSize orgIDStr := orgID.String() userIDStr := userID.String() log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path) // Basic search and pagination. Returns only direct children of the given path. // For root ("/"), we want files where path doesn't contain "/" after the first character. // For subdirs, we want files where path starts with parent but has no additional "/" after parent. rows, err := db.QueryContext(ctx, ` SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at FROM files f WHERE f.org_id = $1 AND EXISTS ( SELECT 1 FROM memberships m WHERE m.org_id = $1 AND m.user_id = $2 ) AND f.path != $3 AND ( ($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%') OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%') ) AND ($4 = '' OR f.name ILIKE '%' || $4 || '%') ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name LIMIT $5 OFFSET $6 `, orgID, userID, path, q, pageSize, offset) if err != nil { return nil, err } defer rows.Close() var files []File for rows.Next() { var f File var orgNull sql.NullString var userNull sql.NullString if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } files = append(files, f) } err = rows.Err() if err == nil { log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path) } return files, err } // GetAllOrgFilesUnderPath returns all files recursively under the given path for an org func (db *DB) GetAllOrgFilesUnderPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) ([]File, error) { orgIDStr := orgID.String() userIDStr := userID.String() log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=%s, userId=%s, path=%s", orgIDStr, userIDStr, path) rows, err := db.QueryContext(ctx, ` SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at FROM files f WHERE f.org_id = $1 AND EXISTS ( SELECT 1 FROM memberships m WHERE m.org_id = $1 AND m.user_id = $2 ) AND f.path LIKE $3 || '%' AND f.path != $3 ORDER BY f.path `, orgID, userID, path) if err != nil { return nil, err } defer rows.Close() var files []File for rows.Next() { var f File var orgNull sql.NullString var userNull sql.NullString if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } files = append(files, f) } err = rows.Err() if err == nil { log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path) } return files, err } // GetUserFiles returns files for a user's personal workspace at a given path func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 100 } offset := (page - 1) * pageSize // Return only direct children of the given path log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path) rows, err := db.QueryContext(ctx, ` SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at FROM files WHERE user_id = $1 AND org_id IS NULL AND path != $2 AND ( ($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%') OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%') ) AND ($3 = '' OR name ILIKE '%' || $3 || '%') ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name LIMIT $4 OFFSET $5 `, userID, path, q, pageSize, offset) if err != nil { return nil, err } defer rows.Close() var files []File for rows.Next() { var f File var orgNull sql.NullString var userNull sql.NullString if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } files = append(files, f) } err = rows.Err() if err == nil { log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path) } return files, err } // GetAllUserFilesUnderPath returns all files recursively under the given path for a user func (db *DB) GetAllUserFilesUnderPath(ctx context.Context, userID uuid.UUID, path string) ([]File, error) { // Return all descendants of the given path log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=, userId=%s, path=%s", userID.String(), path) rows, err := db.QueryContext(ctx, ` SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at FROM files WHERE user_id = $1 AND org_id IS NULL AND path LIKE $2 || '%' AND path != $2 ORDER BY path `, userID, path) if err != nil { return nil, err } defer rows.Close() var files []File for rows.Next() { var f File var orgNull sql.NullString var userNull sql.NullString if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } files = append(files, f) } err = rows.Err() if err == nil { log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path) } return files, err } // CreateFile inserts a file or folder record. orgID or userID may be nil. func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) { var f File var orgIDVal interface{} var userIDVal interface{} orgIDStr := "" userIDStr := "" if orgID != nil { orgIDVal = *orgID orgIDStr = orgID.String() } else { orgIDVal = nil } if userID != nil { userIDVal = *userID userIDStr = userID.String() } else { userIDVal = nil } log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path) err := db.QueryRowContext(ctx, ` INSERT INTO files (org_id, user_id, name, path, type, size) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at `, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt) if err != nil { return nil, err } log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path) return &f, nil } // GetFileByID retrieves a file by its ID func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) { var f File var orgNull sql.NullString var userNull sql.NullString var modifiedByNull sql.NullString var modifiedByNameNull sql.NullString err := db.QueryRowContext(ctx, ` SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at, f.modified_by::text, u.display_name FROM files f LEFT JOIN users u ON f.modified_by = u.id WHERE f.id = $1 `, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt, &modifiedByNull, &modifiedByNameNull) if err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } if modifiedByNull.Valid { mid, _ := uuid.Parse(modifiedByNull.String) f.ModifiedBy = &mid } if modifiedByNameNull.Valid { f.ModifiedByName = modifiedByNameNull.String } return &f, nil } // GetOrgFileByPath returns a file by path for an org func (db *DB) GetOrgFileByPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) (*File, error) { var f File var orgNull sql.NullString var userNull sql.NullString var modifiedByNull sql.NullString var modifiedByNameNull sql.NullString err := db.QueryRowContext(ctx, ` SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at, f.modified_by::text, u.display_name FROM files f LEFT JOIN users u ON f.modified_by = u.id WHERE f.org_id = $1 AND EXISTS ( SELECT 1 FROM memberships m WHERE m.org_id = $1 AND m.user_id = $2 ) AND f.path = $3 `, orgID, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt, &modifiedByNull, &modifiedByNameNull) if err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } if modifiedByNull.Valid { mid, _ := uuid.Parse(modifiedByNull.String) f.ModifiedBy = &mid } if modifiedByNameNull.Valid { f.ModifiedByName = modifiedByNameNull.String } return &f, nil } // GetUserFileByPath returns a file by path for a user func (db *DB) GetUserFileByPath(ctx context.Context, userID uuid.UUID, path string) (*File, error) { var f File var orgNull sql.NullString var userNull sql.NullString var modifiedByNull sql.NullString var modifiedByNameNull sql.NullString err := db.QueryRowContext(ctx, ` SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at, f.modified_by::text, u.display_name FROM files f LEFT JOIN users u ON f.modified_by = u.id WHERE f.user_id = $1 AND f.org_id IS NULL AND f.path = $2 `, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt, &modifiedByNull, &modifiedByNameNull) if err != nil { return nil, err } if orgNull.Valid { oid, _ := uuid.Parse(orgNull.String) f.OrgID = &oid } if userNull.Valid { uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } if modifiedByNull.Valid { mid, _ := uuid.Parse(modifiedByNull.String) f.ModifiedBy = &mid } if modifiedByNameNull.Valid { f.ModifiedByName = modifiedByNameNull.String } return &f, nil } // UpdateFileSize updates the size, modification time, and modifier of a file func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE files SET size = $1, last_modified = NOW(), modified_by = $3 WHERE id = $2 `, size, fileID, modifiedBy) return err } // DeleteFileByPath removes a file or folder matching path for a given org or user func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error { var res sql.Result var err error if orgID != nil { res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path) } else if userID != nil { res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path) } else { return nil } if err != nil { return err } _, _ = res.RowsAffected() return nil } // Passkey-related methods func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) { var user User err := db.QueryRowContext(ctx, ` INSERT INTO users (id, username, email, display_name, password_hash) VALUES (gen_random_uuid(), $1, $2, $3, $4) RETURNING id, username, email, display_name, password_hash, created_at, last_login_at `, username, email, displayName, passwordHash).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt) if err != nil { return nil, err } return &user, nil } func (db *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) { var user User err := db.QueryRowContext(ctx, ` SELECT id, username, email, display_name, password_hash, created_at, last_login_at FROM users WHERE username = $1 `, username).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt) if err != nil { return nil, err } return &user, nil } func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) { var user User err := db.QueryRowContext(ctx, ` SELECT id, username, email, display_name, password_hash, created_at, last_login_at FROM users WHERE email = $1 `, email).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt) if err != nil { return nil, err } return &user, nil } func (db *DB) GetUserByID(ctx context.Context, userID uuid.UUID) (*User, error) { var user User err := db.QueryRowContext(ctx, ` SELECT id, username, email, display_name, password_hash, created_at, last_login_at FROM users WHERE id = $1 `, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt) if err != nil { return nil, err } return &user, nil } func (db *DB) UpdateUserLastLogin(ctx context.Context, userID uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE users SET last_login_at = NOW() WHERE id = $1 `, userID) return err } func (db *DB) CreateCredential(ctx context.Context, cred *Credential) error { _, err := db.ExecContext(ctx, ` INSERT INTO credentials (id, user_id, credential_public_key, credential_id, sign_count, transports) VALUES ($1, $2, $3, $4, $5, $6) `, cred.ID, cred.UserID, cred.CredentialPublicKey, cred.CredentialID, cred.SignCount, cred.Transports) return err } func (db *DB) GetCredentialByID(ctx context.Context, credentialID []byte) (*Credential, error) { var cred Credential err := db.QueryRowContext(ctx, ` SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports FROM credentials WHERE credential_id = $1 `, credentialID).Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports) if err != nil { return nil, err } return &cred, nil } func (db *DB) GetUserCredentials(ctx context.Context, userID uuid.UUID) ([]Credential, error) { rows, err := db.QueryContext(ctx, ` SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports FROM credentials WHERE user_id = $1 ORDER BY created_at DESC `, userID) if err != nil { return nil, err } defer rows.Close() var credentials []Credential for rows.Next() { var cred Credential err := rows.Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports) if err != nil { return nil, err } credentials = append(credentials, cred) } return credentials, rows.Err() } func (db *DB) UpdateCredentialLastUsed(ctx context.Context, credentialID string) error { _, err := db.ExecContext(ctx, ` UPDATE credentials SET last_used_at = NOW() WHERE id = $1 `, credentialID) return err } func (db *DB) CreateAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error { _, err := db.ExecContext(ctx, ` INSERT INTO auth_challenges (user_id, challenge, challenge_type, expires_at) VALUES ($1, $2, $3, NOW() + INTERVAL '15 minutes') `, userID, challenge, challengeType) return err } func (db *DB) VerifyAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error { var count int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM auth_challenges WHERE user_id = $1 AND challenge = $2 AND challenge_type = $3 AND expires_at > NOW() AND used_at IS NULL `, userID, challenge, challengeType).Scan(&count) if err != nil { return err } if count == 0 { return sql.ErrNoRows } return nil } func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error { _, err := db.ExecContext(ctx, ` UPDATE auth_challenges SET used_at = NOW() WHERE challenge = $1 AND used_at IS NULL `, challenge) return err } // FileShareLink methods // CreateFileShareLink creates a new share link for a file func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID, orgID, createdByUserID uuid.UUID) (*models.FileShareLink, error) { var link models.FileShareLink err := db.QueryRowContext(ctx, ` INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id) VALUES ($1, $2, $3, $4) RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked `, token, fileID, orgID, createdByUserID).Scan( &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, &link.CreatedAt, &link.UpdatedAt, &link.ExpiresAt, &link.IsRevoked) return &link, err } // GetFileShareLinkByFileID gets the active share link for a file func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) { var link models.FileShareLink var expiresAtNull sql.NullTime err := db.QueryRowContext(ctx, ` SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked FROM file_share_links WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT 1 `, fileID).Scan( &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, &link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked) if err != nil { return nil, err } if expiresAtNull.Valid { link.ExpiresAt = &expiresAtNull.Time } return &link, nil } // GetFileShareLinkByToken gets a share link by token func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) { var link models.FileShareLink var expiresAtNull sql.NullTime err := db.QueryRowContext(ctx, ` SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked FROM file_share_links WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW()) `, token).Scan( &link.ID, &link.Token, &link.FileID, &link.OrgID, &link.CreatedByUserID, &link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked) if err != nil { return nil, err } if expiresAtNull.Valid { link.ExpiresAt = &expiresAtNull.Time } return &link, nil } // RevokeFileShareLink revokes a share link func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE file_share_links SET is_revoked = TRUE, updated_at = NOW() WHERE file_id = $1 AND is_revoked = FALSE `, fileID) return err }