Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/auth/register/lookup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
found: true,
alreadyRegistered: true,
message: "บัญชีนี้สมัครสมาชิกแล้ว กรุณาเข้าสู่ระบบ",
message: "คุณเคยสมัครสมาชิกไปแล้ว กรุณาเข้าสู่ระบบ",
});
}

Expand Down
2 changes: 2 additions & 0 deletions app/setup/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 0 additions & 5 deletions app/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,10 +69,6 @@ export default async function SetupPage() {
redirect("/");
}

if (status.databaseReady) {
await ensureSetupActionCookie();
}

const initialState = await loadWizardInitialState(status);

return (
Expand Down
35 changes: 35 additions & 0 deletions lib/setup/ensure-accounts-table.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (await tableExists(sql, "public", "accounts")) {
return;
}

const accountsSql = loadAccountsMigrationSql();
if (accountsSql) {
await sql.unsafe(accountsSql);
}
}

export async function ensureAccountsTable(): Promise<void> {
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 });
}
}
43 changes: 43 additions & 0 deletions lib/setup/ensure-storage-buckets.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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 });
}
}
31 changes: 13 additions & 18 deletions lib/setup/hydrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -42,8 +43,9 @@ export async function hydrateDatabase(): Promise<HydrationResult> {
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" };
}

Expand All @@ -53,8 +55,9 @@ export async function hydrateDatabase(): Promise<HydrationResult> {
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" };
}

Expand All @@ -67,6 +70,9 @@ export async function hydrateDatabase(): Promise<HydrationResult> {
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);
Expand All @@ -83,20 +89,9 @@ export async function hydrateDatabase(): Promise<HydrationResult> {
}

async function backfillSetupStatusIfNeeded(sql: postgres.Sql): Promise<void> {
await sql.unsafe(RESET_FALSE_SETUP_WITHOUT_ADMIN_SQL);
await sql.unsafe(LEGACY_SETUP_BACKFILL_SQL);
}

async function ensureStorageBuckets(sql: postgres.Sql): Promise<void> {
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);
}
7 changes: 7 additions & 0 deletions lib/setup/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion lib/student-auth-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
161 changes: 161 additions & 0 deletions supabase/migrations/20260616152000_create_unified_accounts.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading