From 434f3169633bbebb03ee459a0bfc7a4df344f38d Mon Sep 17 00:00:00 2001 From: athivaratz Date: Thu, 16 Jul 2026 22:46:32 +0700 Subject: [PATCH] Update registration messages and enhance setup actions - Updated registration error messages for clarity in Thai language. - Added `ensureStorageBuckets` call in the `saveBrandingAction` function to ensure storage buckets are created before uploading logos. - Removed unnecessary cookie setup check in the `SetupPage` component. - Refactored database hydration logic to ensure accounts table and storage buckets are created as needed. - Introduced new SQL migration functions for managing accounts and storage policies in the database. --- app/api/auth/register/lookup/route.ts | 2 +- app/setup/actions.ts | 2 + app/setup/page.tsx | 5 - lib/setup/ensure-accounts-table.ts | 35 ++++ lib/setup/ensure-storage-buckets.ts | 43 +++++ lib/setup/hydrator.ts | 31 ++-- lib/setup/schemas/index.ts | 7 + lib/student-auth-server.ts | 2 +- ...20260616152000_create_unified_accounts.sql | 161 ++++++++++++++++++ .../20260708100000_setup_storage_buckets.sql | 69 +++++--- 10 files changed, 313 insertions(+), 44 deletions(-) create mode 100644 lib/setup/ensure-accounts-table.ts create mode 100644 lib/setup/ensure-storage-buckets.ts create mode 100644 supabase/migrations/20260616152000_create_unified_accounts.sql diff --git a/app/api/auth/register/lookup/route.ts b/app/api/auth/register/lookup/route.ts index 5e16551..1c55bbd 100644 --- a/app/api/auth/register/lookup/route.ts +++ b/app/api/auth/register/lookup/route.ts @@ -30,7 +30,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ found: true, alreadyRegistered: true, - message: "บัญชีนี้สมัครสมาชิกแล้ว กรุณาเข้าสู่ระบบ", + message: "คุณเคยสมัครสมาชิกไปแล้ว กรุณาเข้าสู่ระบบ", }); } diff --git a/app/setup/actions.ts b/app/setup/actions.ts index bd5f75c..c81adb5 100644 --- a/app/setup/actions.ts +++ b/app/setup/actions.ts @@ -39,6 +39,7 @@ import { wizardAdminSchema } from "@/lib/setup/validations/wizard-admin"; import { OG_METADATA_CACHE_TAG } from "@/lib/seo-metadata"; import { clearAiCredentialsCache } from "@/lib/ai/credentials-resolver"; import { hasMinimumSetupEnv } from "@/lib/setup/db-url"; +import { ensureStorageBuckets } from "@/lib/setup/ensure-storage-buckets"; export type SetupActionResult = | { ok: true } @@ -119,6 +120,7 @@ export async function saveBrandingAction( let logoUrl: string | undefined; const logoFile = formData.get("logo"); if (logoFile instanceof File && logoFile.size > 0) { + await ensureStorageBuckets(); const { mime, ext } = await validateLogoFile(logoFile); const path = `logo-${Date.now()}.${ext}`; logoUrl = await uploadToSupabaseBucket( diff --git a/app/setup/page.tsx b/app/setup/page.tsx index e735568..f0e366f 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -2,7 +2,6 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import { SetupPageClient } from "./setup-page-client"; import { fetchSetupStatusAdmin } from "@/lib/setup/setup-status-server"; -import { ensureSetupActionCookie } from "@/lib/setup/setup-auth"; import { getAiCredentialsData, getSchoolBrandingData, @@ -70,10 +69,6 @@ export default async function SetupPage() { redirect("/"); } - if (status.databaseReady) { - await ensureSetupActionCookie(); - } - const initialState = await loadWizardInitialState(status); return ( diff --git a/lib/setup/ensure-accounts-table.ts b/lib/setup/ensure-accounts-table.ts new file mode 100644 index 0000000..25dbbb9 --- /dev/null +++ b/lib/setup/ensure-accounts-table.ts @@ -0,0 +1,35 @@ +import postgres from "postgres"; +import { resolvePostgresUrl } from "@/lib/setup/db-url"; +import { tableExists } from "@/lib/setup/probe"; +import { loadAccountsMigrationSql } from "@/lib/setup/schemas"; + +export async function ensureAccountsTableWithSql( + sql: postgres.Sql +): Promise { + if (await tableExists(sql, "public", "accounts")) { + return; + } + + const accountsSql = loadAccountsMigrationSql(); + if (accountsSql) { + await sql.unsafe(accountsSql); + } +} + +export async function ensureAccountsTable(): Promise { + const connectionString = resolvePostgresUrl(); + if (!connectionString) return; + + const sql = postgres(connectionString, { + max: 1, + idle_timeout: 5, + connect_timeout: 15, + prepare: false, + }); + + try { + await ensureAccountsTableWithSql(sql); + } finally { + await sql.end({ timeout: 5 }); + } +} diff --git a/lib/setup/ensure-storage-buckets.ts b/lib/setup/ensure-storage-buckets.ts new file mode 100644 index 0000000..34f3622 --- /dev/null +++ b/lib/setup/ensure-storage-buckets.ts @@ -0,0 +1,43 @@ +import postgres from "postgres"; +import { resolvePostgresUrl } from "@/lib/setup/db-url"; +import { tableExists } from "@/lib/setup/probe"; +import { loadStorageMigrationSql } from "@/lib/setup/schemas"; + +export async function ensureStorageBucketsWithSql( + sql: postgres.Sql +): Promise { + if (!(await tableExists(sql, "storage", "buckets"))) { + return; + } + + const rows = await sql<{ name: string }[]>` + SELECT name FROM storage.buckets WHERE name IN ('school-branding', 'item-uploads') + `; + const existing = new Set(rows.map((r) => r.name)); + if (existing.has("school-branding") && existing.has("item-uploads")) { + return; + } + + const storageSql = loadStorageMigrationSql(); + if (storageSql) { + await sql.unsafe(storageSql); + } +} + +export async function ensureStorageBuckets(): Promise { + const connectionString = resolvePostgresUrl(); + if (!connectionString) return; + + const sql = postgres(connectionString, { + max: 1, + idle_timeout: 5, + connect_timeout: 15, + prepare: false, + }); + + try { + await ensureStorageBucketsWithSql(sql); + } finally { + await sql.end({ timeout: 5 }); + } +} diff --git a/lib/setup/hydrator.ts b/lib/setup/hydrator.ts index 67193e1..83a014d 100644 --- a/lib/setup/hydrator.ts +++ b/lib/setup/hydrator.ts @@ -3,12 +3,13 @@ import { LEGACY_SETUP_BACKFILL_SQL, RESET_FALSE_SETUP_WITHOUT_ADMIN_SQL, } from "@/lib/setup/backfill-sql"; +import { ensureAccountsTableWithSql } from "@/lib/setup/ensure-accounts-table"; +import { ensureStorageBucketsWithSql } from "@/lib/setup/ensure-storage-buckets"; import { SETUP_ADVISORY_LOCK_ID } from "@/lib/setup/constants"; import { resolvePostgresUrl } from "@/lib/setup/db-url"; -import { probeDatabaseState } from "@/lib/setup/probe"; +import { probeDatabaseState, tableExists } from "@/lib/setup/probe"; import { loadAllMigrationSql, - loadStorageMigrationSql, loadSystemConfigMigrationSql, } from "@/lib/setup/schemas"; @@ -42,8 +43,9 @@ export async function hydrateDatabase(): Promise { const state = await probeDatabaseState(sql); if (state.hasSystemConfig && state.hasLostItems) { + await ensureAccountsTableWithSql(sql); await backfillSetupStatusIfNeeded(sql); - await ensureStorageBuckets(sql); + await ensureStorageBucketsWithSql(sql); return { ok: true, mode: "skipped" }; } @@ -53,8 +55,9 @@ export async function hydrateDatabase(): Promise { return { ok: false, reason: "no_migrations" }; } await runSqlBatch(sql, systemConfigSql); + await ensureAccountsTableWithSql(sql); await backfillSetupStatusIfNeeded(sql); - await ensureStorageBuckets(sql); + await ensureStorageBucketsWithSql(sql); return { ok: true, mode: "system_config_only" }; } @@ -67,6 +70,9 @@ export async function hydrateDatabase(): Promise { await runSqlBatch(sql, migration.sql); } + await ensureAccountsTableWithSql(sql); + await ensureStorageBucketsWithSql(sql); + return { ok: true, mode: "full" }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -83,20 +89,9 @@ export async function hydrateDatabase(): Promise { } async function backfillSetupStatusIfNeeded(sql: postgres.Sql): Promise { - await sql.unsafe(RESET_FALSE_SETUP_WITHOUT_ADMIN_SQL); - await sql.unsafe(LEGACY_SETUP_BACKFILL_SQL); -} - -async function ensureStorageBuckets(sql: postgres.Sql): Promise { - const rows = await sql<{ name: string }[]>` - SELECT name FROM storage.buckets WHERE name IN ('school-branding', 'item-uploads') - `; - const existing = new Set(rows.map((r) => r.name)); - if (existing.has("school-branding") && existing.has("item-uploads")) { + if (!(await tableExists(sql, "public", "accounts"))) { return; } - const storageSql = loadStorageMigrationSql(); - if (storageSql) { - await runSqlBatch(sql, storageSql); - } + await sql.unsafe(RESET_FALSE_SETUP_WITHOUT_ADMIN_SQL); + await sql.unsafe(LEGACY_SETUP_BACKFILL_SQL); } diff --git a/lib/setup/schemas/index.ts b/lib/setup/schemas/index.ts index 73abcde..5239f82 100644 --- a/lib/setup/schemas/index.ts +++ b/lib/setup/schemas/index.ts @@ -33,3 +33,10 @@ export function loadStorageMigrationSql(): string | null { const file = listMigrationFiles().find((name) => name.includes("setup_storage_buckets")); return file ? readMigrationSql(file) : null; } + +export function loadAccountsMigrationSql(): string | null { + const file = listMigrationFiles().find((name) => + name.includes("create_unified_accounts") + ); + return file ? readMigrationSql(file) : null; +} diff --git a/lib/student-auth-server.ts b/lib/student-auth-server.ts index e76adc6..9ee2847 100644 --- a/lib/student-auth-server.ts +++ b/lib/student-auth-server.ts @@ -1187,7 +1187,7 @@ export async function registerStudentAccount(input: { const account = await getStudentAccount(id); if (!account) return { ok: false, error: "ไม่พบเลขประจำตัวในระบบ" }; if (account.status === "disabled") return { ok: false, error: "บัญชีนี้ถูกปิดใช้งาน" }; - if (account.isRegistered) return { ok: false, error: "บัญชีนี้สมัครสมาชิกแล้ว กรุณาเข้าสู่ระบบ" }; + if (account.isRegistered) return { ok: false, error: "คุณเคยสมัครสมาชิกไปแล้ว กรุณาเข้าสู่ระบบ" }; const displayName = `${account.firstName} ${account.lastName}`.trim(); const passwordHash = hashSecret(input.password); diff --git a/supabase/migrations/20260616152000_create_unified_accounts.sql b/supabase/migrations/20260616152000_create_unified_accounts.sql new file mode 100644 index 0000000..a1b1df7 --- /dev/null +++ b/supabase/migrations/20260616152000_create_unified_accounts.sql @@ -0,0 +1,161 @@ +-- Unified accounts table (merges profiles + student_accounts for app runtime) +CREATE TABLE IF NOT EXISTS public.accounts ( + id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + student_id char(5) UNIQUE, + email text NOT NULL DEFAULT '', + display_name text NOT NULL DEFAULT '', + photo_url text, + role public.user_role NOT NULL DEFAULT 'user', + first_name text, + last_name text, + nickname text, + shown_name text, + is_student_verified boolean NOT NULL DEFAULT false, + auth_methods text[] DEFAULT '{}', + must_change_password boolean NOT NULL DEFAULT false, + has_seen_tutorial boolean NOT NULL DEFAULT false, + ban_status public.ban_status NOT NULL DEFAULT 'none', + ban_reason text, + banned_at timestamptz, + banned_by uuid, + timeout_until timestamptz, + school_password_hash text, + current_password_hash text, + has_logged_in_once boolean NOT NULL DEFAULT false, + linked_uid uuid REFERENCES auth.users(id) ON DELETE SET NULL, + pin_hash text, + passkey_credentials jsonb NOT NULL DEFAULT '[]'::jsonb, + status public.student_account_status NOT NULL DEFAULT 'active', + import_batch_id text, + grade_level text, + room_number text, + is_registered boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_accounts_student_id ON public.accounts(student_id); +CREATE INDEX IF NOT EXISTS idx_accounts_linked_uid ON public.accounts(linked_uid) WHERE linked_uid IS NOT NULL; + +DROP TRIGGER IF EXISTS accounts_updated_at ON public.accounts; +CREATE TRIGGER accounts_updated_at + BEFORE UPDATE ON public.accounts + FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- Migrate legacy rows when upgrading from profiles + student_accounts +INSERT INTO public.accounts ( + id, + student_id, + email, + display_name, + photo_url, + role, + first_name, + last_name, + nickname, + shown_name, + is_student_verified, + auth_methods, + must_change_password, + has_seen_tutorial, + ban_status, + ban_reason, + banned_at, + banned_by, + timeout_until, + school_password_hash, + current_password_hash, + has_logged_in_once, + linked_uid, + pin_hash, + passkey_credentials, + status, + import_batch_id, + is_registered, + created_at, + updated_at +) +SELECT + p.id, + COALESCE(p.student_id, sa.student_id), + p.email, + p.display_name, + p.photo_url, + p.role, + COALESCE(p.first_name, sa.first_name), + COALESCE(p.last_name, sa.last_name), + COALESCE(p.nickname, sa.nickname), + p.shown_name, + p.is_student_verified, + p.auth_methods, + COALESCE(p.must_change_password, sa.must_change_password), + p.has_seen_tutorial, + p.ban_status, + p.ban_reason, + p.banned_at, + p.banned_by, + p.timeout_until, + sa.school_password_hash, + sa.current_password_hash, + COALESCE(sa.has_logged_in_once, false), + COALESCE(sa.linked_uid, p.id), + sa.pin_hash, + COALESCE(sa.passkey_credentials, '[]'::jsonb), + COALESCE(sa.status, 'active'::public.student_account_status), + sa.import_batch_id, + COALESCE(sa.has_logged_in_once, false) OR sa.current_password_hash IS NOT NULL, + LEAST(p.created_at, COALESCE(sa.created_at, p.created_at)), + GREATEST(p.updated_at, COALESCE(sa.updated_at, p.updated_at)) +FROM public.profiles p +LEFT JOIN public.student_accounts sa + ON sa.student_id = p.student_id OR sa.linked_uid = p.id +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.accounts ( + id, + student_id, + email, + display_name, + first_name, + last_name, + nickname, + school_password_hash, + current_password_hash, + must_change_password, + has_logged_in_once, + linked_uid, + pin_hash, + passkey_credentials, + status, + import_batch_id, + is_registered, + created_at, + updated_at +) +SELECT + sa.linked_uid, + sa.student_id, + COALESCE(sa.linked_uid::text, sa.student_id) || '@students.local', + TRIM(sa.first_name || ' ' || sa.last_name), + sa.first_name, + sa.last_name, + sa.nickname, + sa.school_password_hash, + sa.current_password_hash, + sa.must_change_password, + sa.has_logged_in_once, + sa.linked_uid, + sa.pin_hash, + sa.passkey_credentials, + sa.status, + sa.import_batch_id, + sa.has_logged_in_once OR sa.current_password_hash IS NOT NULL, + sa.created_at, + sa.updated_at +FROM public.student_accounts sa +WHERE sa.linked_uid IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM public.accounts a + WHERE a.student_id = sa.student_id OR a.id = sa.linked_uid + ) +ON CONFLICT (id) DO NOTHING; diff --git a/supabase/migrations/20260708100000_setup_storage_buckets.sql b/supabase/migrations/20260708100000_setup_storage_buckets.sql index dd0f60f..0a69539 100644 --- a/supabase/migrations/20260708100000_setup_storage_buckets.sql +++ b/supabase/migrations/20260708100000_setup_storage_buckets.sql @@ -21,25 +21,56 @@ VALUES ON CONFLICT (id) DO NOTHING; -- Public read for branding assets -CREATE POLICY school_branding_public_read - ON storage.objects FOR SELECT - TO public - USING (bucket_id = 'school-branding'); +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'storage' AND tablename = 'objects' + AND policyname = 'school_branding_public_read' + ) THEN + CREATE POLICY school_branding_public_read + ON storage.objects FOR SELECT + TO public + USING (bucket_id = 'school-branding'); + END IF; +END $$; -CREATE POLICY school_branding_service_write - ON storage.objects FOR ALL - TO service_role - USING (bucket_id = 'school-branding') - WITH CHECK (bucket_id = 'school-branding'); +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'storage' AND tablename = 'objects' + AND policyname = 'school_branding_service_write' + ) THEN + CREATE POLICY school_branding_service_write + ON storage.objects FOR ALL + TO service_role + USING (bucket_id = 'school-branding') + WITH CHECK (bucket_id = 'school-branding'); + END IF; +END $$; --- Public read for item images (lost/found) -CREATE POLICY item_uploads_public_read - ON storage.objects FOR SELECT - TO public - USING (bucket_id = 'item-uploads'); +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'storage' AND tablename = 'objects' + AND policyname = 'item_uploads_public_read' + ) THEN + CREATE POLICY item_uploads_public_read + ON storage.objects FOR SELECT + TO public + USING (bucket_id = 'item-uploads'); + END IF; +END $$; -CREATE POLICY item_uploads_service_write - ON storage.objects FOR ALL - TO service_role - USING (bucket_id = 'item-uploads') - WITH CHECK (bucket_id = 'item-uploads'); +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'storage' AND tablename = 'objects' + AND policyname = 'item_uploads_service_write' + ) THEN + CREATE POLICY item_uploads_service_write + ON storage.objects FOR ALL + TO service_role + USING (bucket_id = 'item-uploads') + WITH CHECK (bucket_id = 'item-uploads'); + END IF; +END $$;