package store import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/loyaly/behavision-server/internal/api" ) // Adding people to a company, and taking them out again. // // Registration here is by invitation only. `handlers_team.go` carries the // product argument; what matters at this layer is that every statement is // scoped by the CALLER'S client id, taken from their session, so a manager // cannot invite somebody into, list, or remove a member of a company that is // not theirs by guessing a uuid. // CreateInvitation writes a pending invitation for one company. // // The client id is not trusted from a caller anywhere above this, but it is // still joined against `clients` here rather than inserted blind: a foreign-key // violation surfaces as an opaque 500, and a row that names a company which has // since been deleted is worse than a clean refusal. func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) { var out api.Invitation err := s.pool.QueryRow(ctx, ` INSERT INTO invitations (client_id, email, full_name, role, code_hash, invited_by, expires_at) SELECT c.id, $2, $3, $4, $5, $6::uuid, $7 FROM clients c WHERE c.id = $1::uuid RETURNING id::text, email, full_name, role, to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash, nullUUID(in.InvitedBy), in.ExpiresAt, ).Scan(&out.ID, &out.Email, &out.FullName, &out.Role, &out.ExpiresAt, &out.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return api.Invitation{}, errors.New("no such company") } if err != nil { return api.Invitation{}, fmt.Errorf("create invitation: %w", err) } return out, nil } // PendingInvitations lists the invitations that have been sent and not yet // taken up. Spent and revoked rows are history and are deliberately not here: // the question this list answers is "who is still waiting to join". func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) { rows, err := s.pool.Query(ctx, ` SELECT i.id::text, i.email, i.full_name, i.role, COALESCE(u.full_name, u.email, ''), to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') FROM invitations i LEFT JOIN app_users u ON u.id = i.invited_by WHERE i.client_id = $1::uuid AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > now() ORDER BY i.created_at DESC`, clientID) if err != nil { return nil, fmt.Errorf("list invitations: %w", err) } defer rows.Close() var out []api.Invitation for rows.Next() { var v api.Invitation if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role, &v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // RevokeInvitation withdraws one before it is used. // // Scoped by client in the UPDATE, and it refuses an already-spent invitation // rather than silently doing nothing: "I revoked it" and "somebody had already // joined with it" need opposite follow-up actions from whoever asked. func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error { tag, err := s.pool.Exec(ctx, ` UPDATE invitations SET revoked_at = now() WHERE id = $2::uuid AND client_id = $1::uuid AND used_at IS NULL AND revoked_at IS NULL`, clientID, id) if err != nil { return fmt.Errorf("revoke invitation: %w", err) } if tag.RowsAffected() == 0 { return errors.New("no such pending invitation") } return nil } // InvitationByCode is the unauthenticated preview: what a holder may learn // about a code they already have. // // Every way of not being valid returns the same error, so this cannot be used // to tell an expired code from an invented one. func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) { var out api.InvitationPreview err := s.pool.QueryRow(ctx, ` SELECT c.name, i.email, i.full_name, i.role FROM invitations i JOIN clients c ON c.id = i.client_id WHERE i.code_hash = $1 AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > now()`, hash, ).Scan(&out.Client, &out.Email, &out.FullName, &out.Role) if err != nil { return api.InvitationPreview{}, errors.New("that invitation is not valid") } return out, nil } // RedeemInvitation turns a code into an account, in ONE transaction. // // Two properties, and both were learned elsewhere in this system: // // - Single use is enforced BY the update. `used_at IS NULL` and the write are // one statement, so two people racing on one invitation cannot both win. // Check-then-update would be exactly that race, and the loser would get a // second account rather than an error. // - The account and the redemption commit together. A spent invitation with // no user behind it is an invitation nobody can use and nobody can see is // broken; a user with the invitation still open is a second account waiting // to be created by anyone who was forwarded the code. // // The email and the role come from the ROW, never from the request. A code // passed on to a colleague must not become an account for them, and a staff // invitation must not be redeemed as an owner. func (s *Store) RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (api.UserRecord, error) { tx, err := s.pool.Begin(ctx) if err != nil { return api.UserRecord{}, err } defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed var clientID, email, role, invitedName string err = tx.QueryRow(ctx, ` UPDATE invitations SET used_at = now() WHERE code_hash = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now() RETURNING client_id::text, email, role, full_name`, hash, ).Scan(&clientID, &email, &role, &invitedName) if errors.Is(err, pgx.ErrNoRows) { return api.UserRecord{}, errors.New("that invitation is not valid") } if err != nil { return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err) } if fullName == "" { // The inviter may have typed a name; use it rather than leaving a // blank row that every screen then renders as an email address. fullName = invitedName } var rec api.UserRecord err = tx.QueryRow(ctx, ` INSERT INTO app_users (client_id, email, password_hash, full_name, role) VALUES ($1::uuid, $2, $3, $4, $5) RETURNING id::text, email, full_name, role`, clientID, email, passwordHash, fullName, role, ).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role) if err != nil { return api.UserRecord{}, fmt.Errorf("create user: %w", err) } var clientName string if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`, clientID).Scan(&clientName); err != nil { return api.UserRecord{}, err } // Recorded against the new account, not the inviter: this is the moment a // person gained access, and the row should name who did. rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true if err := tx.Commit(ctx); err != nil { return api.UserRecord{}, err } return rec, nil } // Team lists the people in one company. func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) { rows, err := s.pool.Query(ctx, ` SELECT id::text, email, full_name, role, active, COALESCE(to_char(last_login_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') FROM app_users WHERE client_id = $1::uuid ORDER BY active DESC, full_name, email`, clientID) if err != nil { return nil, fmt.Errorf("list team: %w", err) } defer rows.Close() var out []api.TeamMember for rows.Next() { var m api.TeamMember if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, &m.LastLoginAt, &m.CreatedAt); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // UpdateTeamMember changes a role, or deactivates somebody who has left. // // Deactivating REVOKES their sessions in the same transaction. Leaving them // live would mean "remove their access" removed it in twelve hours' time, // whenever their access token happened to expire - which is not what anybody // pressing that button believes they have just done, and is precisely the case // an opaque-token session table exists to handle. func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string, up api.TeamUpdate) (api.TeamMember, error) { tx, err := s.pool.Begin(ctx) if err != nil { return api.TeamMember{}, err } defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed var m api.TeamMember err = tx.QueryRow(ctx, ` UPDATE app_users SET role = COALESCE($3, role), active = COALESCE($4, active) WHERE id = $2::uuid AND client_id = $1::uuid RETURNING id::text, email, full_name, role, active, COALESCE(to_char(last_login_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, clientID, userID, up.Role, up.Active, ).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, &m.LastLoginAt, &m.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return api.TeamMember{}, errors.New("no such team member") } if err != nil { return api.TeamMember{}, fmt.Errorf("update team member: %w", err) } if up.Active != nil && !*up.Active { if _, err := tx.Exec(ctx, ` UPDATE sessions SET revoked_at = now() WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil { return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err) } } if err := tx.Commit(ctx); err != nil { return api.TeamMember{}, err } return m, nil } // OwnerCount counts the active owners of a company. // // Used to refuse the change that locks a company out of its own account: the // last owner may not demote or deactivate themselves. There is no support path // back from that except a shell on the server, which is the thing this whole // surface exists to stop needing. func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) { var n int err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM app_users WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n) return n, err } // ============================================================== sessions ==== // UserSessions lists one person's live sessions, newest first. func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) { rows, err := s.pool.Query(ctx, ` SELECT id::text, device, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), COALESCE(to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') FROM sessions WHERE user_id = $1::uuid AND revoked_at IS NULL AND refresh_expires_at > now() ORDER BY COALESCE(last_used_at, created_at) DESC`, userID) if err != nil { return nil, fmt.Errorf("list sessions: %w", err) } defer rows.Close() var out []api.DeviceSession for rows.Next() { var d api.DeviceSession if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt, &d.LastUsedAt, &d.ExpiresAt); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // RevokeUserSession signs one device out. // // Scoped by user_id in the UPDATE, so a session id - which is not a secret and // travels in a list - cannot be used to sign somebody else out. func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error { tag, err := s.pool.Exec(ctx, ` UPDATE sessions SET revoked_at = now() WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`, userID, sessionID) if err != nil { return fmt.Errorf("revoke session: %w", err) } if tag.RowsAffected() == 0 { return errors.New("no such session") } return nil } // RevokeOtherSessions is the "sign out everywhere else" button. // // It keeps the caller's own session deliberately: somebody who has just lost a // phone should not also be signed out of the device they are holding, which // would leave them re-authenticating in the middle of an emergency. func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) { tag, err := s.pool.Exec(ctx, ` UPDATE sessions SET revoked_at = now() WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`, userID, keepSessionID) if err != nil { return 0, fmt.Errorf("revoke sessions: %w", err) } return int(tag.RowsAffected()), nil } // CreateMember inserts an active account into a tenant. // // The email uniqueness constraint is global (migration 007), and a clash here // is an ordinary typing mistake - somebody already has that address - so it // surfaces as a conflict the manager can act on, not a 500. func (s *Store) CreateMember(ctx context.Context, clientID string, in api.NewMemberInput, hash string) (api.TeamMember, error) { var m api.TeamMember err := s.pool.QueryRow(ctx, ` INSERT INTO app_users (client_id, email, password_hash, full_name, role) VALUES ($1::uuid, $2, $3, $4, $5) RETURNING id::text, email, full_name, role, active, '', to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, clientID, in.Email, hash, in.FullName, in.Role, ).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, &m.LastLoginAt, &m.CreatedAt) if err != nil { return api.TeamMember{}, fmt.Errorf("create member: %w", err) } return m, nil } // ResetMemberPassword replaces a member's password and signs them out // everywhere, in one transaction. // // The two go together because of why a manager resets a password at all: the // salesperson forgot it, or lost the phone it was saved on. In the second case // the old sessions are the problem, and a reset that left them valid would // look complete while changing nothing that mattered. Scoped to the caller's // tenant in the UPDATE itself, so a user id from another company matches no // row rather than being reset. func (s *Store) ResetMemberPassword(ctx context.Context, clientID, userID, hash string) (api.TeamMember, error) { tx, err := s.pool.Begin(ctx) if err != nil { return api.TeamMember{}, err } defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed var m api.TeamMember err = tx.QueryRow(ctx, ` UPDATE app_users SET password_hash = $3 WHERE id = $2::uuid AND client_id = $1::uuid RETURNING id::text, email, full_name, role, active, COALESCE(to_char(last_login_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, clientID, userID, hash, ).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, &m.LastLoginAt, &m.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return api.TeamMember{}, errors.New("no such team member") } if err != nil { return api.TeamMember{}, fmt.Errorf("reset password: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE sessions SET revoked_at = now() WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil { return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err) } if err := tx.Commit(ctx); err != nil { return api.TeamMember{}, err } return m, nil }