Files
descrybe/apps/api/cmd/migrator/postimport.go
T

177 lines
4.9 KiB
Go
Raw Normal View History

package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
2026-08-17 21:20:45 +02:00
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// SetPasswordHook is a one-time accept-invite style token for a migrated user.
// SMTP delivery is owned by mailhooks / WS7 — this only prepares durable invite rows + a local artifact.
type SetPasswordHook struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
CompanyID uuid.UUID `json:"company_id"`
Role string `json:"role"`
Token string `json:"token"`
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
InviteID uuid.UUID `json:"invite_id,omitempty"`
}
func webOrigin() string {
o := strings.TrimSpace(os.Getenv("WEB_ORIGIN"))
if o == "" {
o = "http://localhost:5174"
}
return strings.TrimRight(o, "/")
}
func setPasswordInviteURL(token string) string {
2026-08-17 21:20:45 +02:00
return mail.AcceptInviteURL(webOrigin(), token)
}
// prepareSetPasswordHooks creates invites for active users with must_set_password=true.
// Tokens are returned once for the artifact (do not commit). AcceptInvite sets password
// only when must_set_password is still true (existing accounts with a password must verify it).
func prepareSetPasswordHooks(
ctx context.Context,
pg *pgxpool.Pool,
ttl time.Duration,
dryRun bool,
report map[string]int,
) ([]SetPasswordHook, error) {
if dryRun {
report["set_password_hooks_skipped_dry_run"]++
return nil, nil
}
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
rows, err := pg.Query(ctx, `
SELECT u.id, u.email, m.company_id, m.role
FROM users u
JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
WHERE u.must_set_password = true AND u.is_active = true
ORDER BY u.email, m.created_at
`)
if err != nil {
return nil, fmt.Errorf("list must_set_password users: %w", err)
}
defer rows.Close()
seen := map[uuid.UUID]bool{}
var hooks []SetPasswordHook
expires := time.Now().UTC().Add(ttl)
for rows.Next() {
var h SetPasswordHook
if err := rows.Scan(&h.UserID, &h.Email, &h.CompanyID, &h.Role); err != nil {
return nil, err
}
if seen[h.UserID] {
continue
}
seen[h.UserID] = true
if auth.IsSyntheticLegacyEmail(h.Email) {
report["set_password_hooks_skipped_synthetic"]++
continue
}
if h.Role == "" {
h.Role = "member"
}
token, err := auth.RandomToken(24)
if err != nil {
return nil, err
}
h.Token = token
h.ExpiresAt = expires
h.URL = setPasswordInviteURL(h.Token)
// Expire prior unaccepted invites for this email+company so re-issue is safe.
_, _ = pg.Exec(ctx, `
UPDATE invites
SET expires_at = least(expires_at, now())
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
h.CompanyID, h.Email)
err = pg.QueryRow(ctx, `
INSERT INTO invites (company_id, email, role, token, expires_at)
VALUES ($1, lower($2), $3, $4, $5)
RETURNING id`,
h.CompanyID, h.Email, h.Role, auth.HashInviteToken(h.Token), h.ExpiresAt,
).Scan(&h.InviteID)
if err != nil {
log.Printf("set-password invite for user_id=%s: %v", h.UserID, err)
report["set_password_hooks_skipped"]++
continue
}
hooks = append(hooks, h)
report["set_password_hooks"]++
}
if err := rows.Err(); err != nil {
return nil, err
}
return hooks, nil
}
func writeSetPasswordArtifacts(mapsDir string, hooks []SetPasswordHook) error {
if len(hooks) == 0 {
return nil
}
if err := os.MkdirAll(mapsDir, 0o755); err != nil {
return err
}
// Canonical mailhooks path + operator-friendly alias with URLs.
hookPath := filepath.Join(mapsDir, "set-password-hooks.json")
invitePath := filepath.Join(mapsDir, "password_invites.json")
if err := writeJSON(hookPath, hooks); err != nil {
return err
}
if err := writeJSON(invitePath, hooks); err != nil {
return err
}
fmt.Printf("wrote %d set-password invites to %s and %s (do not commit)\n", len(hooks), invitePath, hookPath)
fmt.Println("=== Set-password invite URLs ===")
for _, h := range hooks {
fmt.Printf("%s\t%s\n", h.Email, h.URL)
}
return nil
}
// setPasswordByEmail is a local/dev bootstrap: force a password for one migrated user.
func setPasswordByEmail(ctx context.Context, pg *pgxpool.Pool, emailPass string) error {
parts := strings.SplitN(emailPass, ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || parts[1] == "" {
return fmt.Errorf("-set-password expects email:password")
}
email := strings.ToLower(strings.TrimSpace(parts[0]))
password := parts[1]
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
ct, err := pg.Exec(ctx, `
UPDATE users
SET password_hash = $2, must_set_password = false, updated_at = now()
WHERE lower(email) = $1`, email, hash)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return fmt.Errorf("no user with email %s", email)
}
fmt.Printf("set password for %s (must_set_password=false)\n", email)
return nil
}