mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-18 04:26:32 +00:00
feat: scaffold Phase 0 foundation (Next.js 16 + Tailwind v4 + Supabase/Upstash)
Initial code drop for the BSK educational rewrite. Repo previously held only
docs (PLAN.md, README, NOTICE, LICENSE, RESEARCH_REPORT). This commit lands
the App Router shell, i18n route group, and shared-infra factories per the
Phase 0 plan.
Scaffold:
- Next.js 16 + React 19 + TypeScript 5.9, App Router, Turbopack defaults
- Tailwind v4 via @tailwindcss/postcss with CSS-first @theme block
- shadcn/ui CLI v4 (components.json + cn helper); components install lazily
- next-intl v4 with vi default + en fallback; async-params-aware routing
- proxy.ts (Next 16's renamed middleware) wired to next-intl
- lib/supabase/{server,client,admin,session}.ts on @supabase/ssr, schema-scoped
to 'bsk', async cookies(), server factory unsafe inside 'use cache'
- lib/upstash.ts: prefixed cache helpers and Ratelimit v2, QStash signature
verifier; future code cannot write unprefixed Redis keys
- lib/env/{client,server}.ts split so the secret key types stay server-side
- ESLint flat config (eslint-config-next/core-web-vitals + typescript +
prettier), Prettier with tailwindcss plugin, .npmrc + pnpm-workspace.yaml
for pnpm 11 native-build approval
- CI runs format:check, lint, typecheck, build on PR with dummy env
PLAN.md updates:
- §1 reconciled to TypeScript 5.9 (TS 6 is GA but lacks ecosystem support)
- §3.1 notes middleware → proxy file rename and removal of `next lint`
All four gates pass locally: format:check, lint, typecheck, build (SSG for
/vi and /en, Proxy detected). Code-reviewer findings applied: env split,
session helper renamed and docstring fixed, cache.set/del types tightened,
prettierignore scope reduced, bilingual GlobalNotFound, explanatory comments
on no-op layouts and duplicate setRequestLocale.
Deferred to Phase 1: wiring updateSupabaseSession into proxy.ts (needs auth
flow first), schema migrations, sign-in form.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Application
|
||||
NEXT_PUBLIC_APP_ENV=dev # dev | preview | prod — determines Redis key namespace
|
||||
|
||||
# Supabase (new key format — old supabase_key_* retires 2026-12-31)
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= # sb_publishable_* (browser, replaces anon key)
|
||||
SUPABASE_SECRET_KEY= # sb_secret_* (server, replaces service role key)
|
||||
|
||||
# Upstash Redis (shared DB; bsk: prefix enforced by lib/upstash.ts)
|
||||
UPSTASH_REDIS_REST_URL=
|
||||
UPSTASH_REDIS_REST_TOKEN=
|
||||
|
||||
# QStash (background jobs; routes verify signatures via Receiver)
|
||||
QSTASH_URL=https://qstash.upstash.io
|
||||
QSTASH_TOKEN=
|
||||
QSTASH_CURRENT_SIGNING_KEY=
|
||||
QSTASH_NEXT_SIGNING_KEY=
|
||||
@@ -0,0 +1,50 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Lint / Typecheck / Build
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Dummy values so build can resolve env schema.
|
||||
# Real secrets live in the Vercel project, never in CI.
|
||||
NEXT_PUBLIC_APP_ENV: preview
|
||||
NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: sb_publishable_ci_placeholder
|
||||
SUPABASE_SECRET_KEY: sb_secret_ci_placeholder
|
||||
UPSTASH_REDIS_REST_URL: https://example.upstash.io
|
||||
UPSTASH_REDIS_REST_TOKEN: ci_placeholder
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# next
|
||||
.next
|
||||
out
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea
|
||||
.vscode
|
||||
*.tsbuildinfo
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# testing
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,2 @@
|
||||
verify-deps-before-run=false
|
||||
ignored-build-scripts-status-warn-only=true
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.vercel
|
||||
pnpm-lock.yaml
|
||||
*.md
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Contributing to BSK
|
||||
|
||||
Educational rewrite of [lds217/BSK](https://github.com/lds217/BSK-All-in-One-Clinic-Management-System). See `PLAN.md` for the full phased roadmap.
|
||||
|
||||
## Cross-cutting rules
|
||||
|
||||
These apply everywhere. New code that violates them gets rejected at review.
|
||||
|
||||
### 1. Async `params` / `searchParams` (Next.js 16)
|
||||
|
||||
Always `await`:
|
||||
|
||||
```tsx
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { q } = await searchParams;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
If you forget to `await`, the value is a `Promise`, not a string — you get a runtime error, not a type error. The ESLint preset catches most cases. When importing third-party route snippets, run `npx @next/codemod@latest next-async-request-api .`.
|
||||
|
||||
### 2. `'use cache'` constraints
|
||||
|
||||
Implicit App Router caching is gone in Next.js 16. Caching is opt-in via the `'use cache'` directive.
|
||||
|
||||
- Cached functions **cannot** call `cookies()`, `headers()`, or read `searchParams` directly.
|
||||
- Read those at the page/layout level, then pass scalar/serializable values into cached helpers as arguments.
|
||||
|
||||
```tsx
|
||||
// ❌ wrong — cookies() inside cached scope
|
||||
async function getUserDashboard() {
|
||||
"use cache";
|
||||
const cookieStore = await cookies(); // runtime error
|
||||
}
|
||||
|
||||
// ✅ correct
|
||||
export default async function Page() {
|
||||
const supabase = await createSupabaseServerClient(); // reads cookies()
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
return <Dashboard data={await getDashboardData(user!.id)} />;
|
||||
}
|
||||
|
||||
async function getDashboardData(userId: string) {
|
||||
"use cache";
|
||||
// pure: only depends on userId
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Supabase + cache interaction
|
||||
|
||||
`lib/supabase/server.ts` reads cookies → never call it from a `'use cache'` function. Call it at the page/layout/Server-Action level and pass the data (not the client) into cached helpers.
|
||||
|
||||
`lib/supabase/admin.ts` does not depend on cookies and is safe to call from cached scopes — but only when the call is genuinely user-agnostic.
|
||||
|
||||
### 4. Realtime placement
|
||||
|
||||
Supabase Realtime channels are subscribed in **Client Components** (or Route Handlers), never in RSC and never inside `'use cache'`. Cached RSC fetches provide the initial snapshot; Realtime drives deltas.
|
||||
|
||||
Channel names must be prefixed with `bsk:` (e.g. `bsk:queue:{shift_id}`). Since the project's Supabase instance is shared across multiple side projects, unprefixed names will collide.
|
||||
|
||||
### 5. Supabase keys
|
||||
|
||||
Use the new key format:
|
||||
|
||||
- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` — browser, replaces the old anon key.
|
||||
- `SUPABASE_SECRET_KEY` — server-only, replaces the old service role key.
|
||||
|
||||
The legacy `supabase_key_*` format retires 2026-12-31; we do not use it.
|
||||
|
||||
### 6. Tailwind v4 shape
|
||||
|
||||
- PostCSS plugin: `@tailwindcss/postcss` (see `postcss.config.mjs`).
|
||||
- Theme lives in `app/globals.css` under `@theme { … }`. No JS `tailwind.config.ts`.
|
||||
- `shadcn` CLI v4 understands this layout; new components install into `components/ui/`.
|
||||
|
||||
### 7. Forms with React 19 + Server Actions
|
||||
|
||||
The standard recipe:
|
||||
|
||||
- **Schema:** Zod v4 in a shared module (importable from both client and server).
|
||||
- **Client UX:** `react-hook-form` + `@hookform/resolvers/zod` for inline validation.
|
||||
- **Submit:** `useActionState` (React 19) wraps the Server Action; the Server Action re-validates with the same Zod schema.
|
||||
- **No wrappers:** do not introduce `next-safe-action`, `zsa`, or similar. The official React 19 API is sufficient.
|
||||
|
||||
### 8. Shared-infra namespacing
|
||||
|
||||
This project shares a Supabase project, an Upstash Redis DB, and a QStash account with several other side projects. Every persistent identifier must be `bsk`-prefixed:
|
||||
|
||||
| Surface | Prefix | Example |
|
||||
|---|---|---|
|
||||
| Postgres schema | `bsk` | `bsk.patients` |
|
||||
| Migration filename | `bsk_` segment | `20260601000000_bsk_init.sql` |
|
||||
| RLS helper | `bsk.` | `bsk.current_role()` |
|
||||
| Redis key | `bsk:{env}:` | `bsk:prod:cache:queue:42` |
|
||||
| Rate-limit bucket | `bsk:{env}:ratelimit:` | `bsk:prod:ratelimit:login` |
|
||||
| Realtime channel | `bsk:` | `bsk:queue:{shift_id}` |
|
||||
| Storage bucket | `bsk-` | `bsk-checkup-media` |
|
||||
| QStash topic | `bsk-` | `bsk-recheckup-reminders` |
|
||||
|
||||
`lib/upstash.ts` enforces the Redis + rate-limit prefixes — use those helpers, not raw `Redis` instances. `lib/supabase/*.ts` bakes in `db: { schema: 'bsk' }` — use those, not raw `createClient`.
|
||||
|
||||
Never run `KEYS *`, `FLUSHDB`, `FLUSHALL`, or `supabase db reset` against the shared infra.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
pnpm install # one-time
|
||||
pnpm dev # local dev (Turbopack)
|
||||
pnpm lint # ESLint
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # production build
|
||||
pnpm format # prettier --write .
|
||||
```
|
||||
|
||||
## Commit hygiene
|
||||
|
||||
- Conventional commits (`docs:`, `feat:`, `fix:`, `chore:`, `test:`).
|
||||
- One concern per commit.
|
||||
- Scope by feature when useful (`feat(queue): …`).
|
||||
@@ -16,7 +16,7 @@ Versions are pinned to the latest stable as of May 2026. No code is written yet,
|
||||
| Package manager | pnpm |
|
||||
| Framework | **Next.js 16** (App Router, RSC, Server Actions, `'use cache'` directive, Turbopack default) |
|
||||
| Runtime | React 19 — `params` and `searchParams` are **async-only** (must `await`) |
|
||||
| Language | TypeScript 6 (`"strict": true`) |
|
||||
| Language | TypeScript 5.9 (`"strict": true`); revisit TS 6 once `eslint-config-next` + `typescript-eslint` ship support |
|
||||
| Hosting | Vercel (Hobby tier, Fluid Compute pricing) |
|
||||
| DB + Auth + Storage | Supabase (Postgres + Auth + Storage); new `sb_publishable_*` / `sb_secret_*` key format (old keys retire 2026-12-31) |
|
||||
| SSR client | `@supabase/ssr` (async `cookies()` aware) |
|
||||
@@ -113,6 +113,8 @@ These are the cross-cutting changes the plan assumes everywhere; they're called
|
||||
- **Supabase client + `'use cache'` interaction.** Server clients depend on cookies; they must be created *outside* a cached scope. The cached helpers receive a pre-built client (or the data the client returned), not the cookie store.
|
||||
- **Supabase Realtime + `'use cache'`.** Realtime channels are subscribed in Client Components or Route Handlers — never inside `'use cache'`. Cached reads provide the initial snapshot; Realtime drives deltas.
|
||||
- **Turbopack is the default** for `next dev` and `next build`. No custom webpack config unless we have a concrete reason.
|
||||
- **`middleware.ts` renamed to `proxy.ts`.** Next.js 16 deprecates the `middleware` file convention; the root file must be `proxy.ts` (build emits a warning on the old name). The export shape is unchanged — `next-intl`, `@supabase/ssr`, and other libraries still call it "middleware" internally; only the Next.js file convention moved.
|
||||
- **`next lint` removed.** Use `eslint .` directly via `package.json` scripts.
|
||||
- **Supabase API keys.** Use the new `sb_publishable_*` (browser) and `sb_secret_*` (server) keys from day one. Legacy `supabase_key_*` keys retire 2026-12-31.
|
||||
- **Tailwind v4 install shape.** PostCSS config uses `@tailwindcss/postcss`; theme lives in `globals.css` via `@theme`, not in a JS config file. `shadcn init` already scaffolds this layout.
|
||||
- **Forms.** React 19's `useActionState` is the official Server-Action form integration. No `next-safe-action` / `zsa` wrapper needed; pair with `react-hook-form` for client-side UX and Zod v4 for the schema shared between client and server.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextIntlClientProvider, hasLocale } from "next-intl";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import "../globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "BSK Clinic",
|
||||
description: "Educational clinic management rewrite",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body>
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function LocaleNotFound() {
|
||||
const t = await getTranslations("errors");
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6 py-16">
|
||||
<h1 className="text-2xl font-semibold">{t("notFoundTitle")}</h1>
|
||||
<p className="text-muted-foreground">{t("notFoundBody")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
// Required per next-intl static-rendering guide; intentionally duplicated
|
||||
// alongside the same call in `[locale]/layout.tsx`. Do not remove.
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations("home");
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-6 px-6 py-16">
|
||||
<h1 className="text-3xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
<p className="text-muted-foreground text-sm">{t("status", { phase: "0" })}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0 0);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "Courier New", monospace;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@theme {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.985 0 0);
|
||||
--color-primary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.269 0 0);
|
||||
--color-muted-foreground: oklch(0.708 0 0);
|
||||
--color-border: oklch(0.269 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Intentionally empty pass-through. The real `<html>` + `<body>` live in
|
||||
// `app/[locale]/layout.tsx` so `lang={locale}` and the i18n provider can
|
||||
// be set per locale. Do not put global UI here.
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from "next/link";
|
||||
import { routing } from "@/i18n/routing";
|
||||
|
||||
// Outside-locale fallback (URL didn't match any locale segment). Renders
|
||||
// without locale context, so messages are hardcoded in both languages.
|
||||
export default function GlobalNotFound() {
|
||||
return (
|
||||
<html lang={routing.defaultLocale}>
|
||||
<body>
|
||||
<main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 px-6 py-16">
|
||||
<h1 className="text-2xl font-semibold">404</h1>
|
||||
<p>Không tìm thấy trang. / Page not found.</p>
|
||||
<Link href={`/${routing.defaultLocale}`} className="underline">
|
||||
Về trang chủ / Go home
|
||||
</Link>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypeScript from "eslint-config-next/typescript";
|
||||
import prettier from "eslint-config-prettier";
|
||||
|
||||
const config = [
|
||||
{ ignores: [".next/**", "node_modules/**", "dist/**", "out/**", ".vercel/**"] },
|
||||
...nextCoreWebVitals,
|
||||
...nextTypeScript,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNavigation } from "next-intl/navigation";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { hasLocale } from "next-intl";
|
||||
import { routing } from "./routing";
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale;
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
timeZone: "Asia/Ho_Chi_Minh",
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["vi", "en"] as const,
|
||||
defaultLocale: "vi",
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
|
||||
export type Locale = (typeof routing.locales)[number];
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const clientSchema = z.object({
|
||||
NEXT_PUBLIC_APP_ENV: z.enum(["dev", "preview", "prod"]).default("dev"),
|
||||
NEXT_PUBLIC_SUPABASE_URL: z.url(),
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().min(1),
|
||||
});
|
||||
|
||||
const parsed = clientSchema.safeParse({
|
||||
NEXT_PUBLIC_APP_ENV: process.env.NEXT_PUBLIC_APP_ENV,
|
||||
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = Object.entries(parsed.error.flatten().fieldErrors)
|
||||
.map(([k, v]) => ` ${k}: ${(v ?? []).join(", ")}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid public environment variables:\n${issues}`);
|
||||
}
|
||||
|
||||
export const clientEnv = parsed.data;
|
||||
|
||||
export const APP_SLUG = "bsk" as const;
|
||||
export const SUPABASE_SCHEMA = "bsk" as const;
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { APP_SLUG, SUPABASE_SCHEMA, clientEnv } from "./client";
|
||||
|
||||
const serverSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "preview", "production", "test"]).default("development"),
|
||||
|
||||
SUPABASE_SECRET_KEY: z.string().min(1),
|
||||
|
||||
UPSTASH_REDIS_REST_URL: z.url(),
|
||||
UPSTASH_REDIS_REST_TOKEN: z.string().min(1),
|
||||
|
||||
QSTASH_URL: z.url().optional(),
|
||||
QSTASH_TOKEN: z.string().optional(),
|
||||
QSTASH_CURRENT_SIGNING_KEY: z.string().optional(),
|
||||
QSTASH_NEXT_SIGNING_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
const parsed = serverSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = Object.entries(parsed.error.flatten().fieldErrors)
|
||||
.map(([k, v]) => ` ${k}: ${(v ?? []).join(", ")}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid server environment variables:\n${issues}`);
|
||||
}
|
||||
|
||||
export const serverEnv = {
|
||||
...parsed.data,
|
||||
...clientEnv,
|
||||
};
|
||||
|
||||
export { APP_SLUG, SUPABASE_SCHEMA };
|
||||
export const redisKeyPrefix = `${APP_SLUG}:${clientEnv.NEXT_PUBLIC_APP_ENV}` as const;
|
||||
@@ -0,0 +1,18 @@
|
||||
import "server-only";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server";
|
||||
|
||||
/**
|
||||
* Privileged Supabase client (uses the secret key, bypasses RLS on behalf of no user).
|
||||
* Use only for admin tasks: invites, cron sweeps, system writes.
|
||||
* NEVER expose this client to the browser or pass its results through `'use cache'`.
|
||||
*/
|
||||
export function createSupabaseAdminClient() {
|
||||
return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SECRET_KEY, {
|
||||
db: { schema: SUPABASE_SCHEMA },
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { clientEnv, SUPABASE_SCHEMA } from "@/lib/env/client";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(
|
||||
clientEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||
clientEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||
{
|
||||
db: { schema: SUPABASE_SCHEMA },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server";
|
||||
|
||||
/**
|
||||
* Per-request Supabase client for RSC and Server Actions.
|
||||
* MUST be called outside a `'use cache'` scope — it depends on cookies().
|
||||
*/
|
||||
export async function createSupabaseServerClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
serverEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||
serverEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||
{
|
||||
db: { schema: SUPABASE_SCHEMA },
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
cookieStore.set({ name, value, ...options });
|
||||
}
|
||||
} catch {
|
||||
// setAll throws from Server Components; the proxy-layer refresh handles it.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import "server-only";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server";
|
||||
|
||||
/**
|
||||
* Refreshes the Supabase auth session for an incoming request.
|
||||
*
|
||||
* NOT yet wired into `proxy.ts` — that integration lands in Phase 1 along with
|
||||
* the sign-in flow. When wiring it in, call this BEFORE handing the request to
|
||||
* `next-intl/middleware`, and merge the returned response's Set-Cookie headers
|
||||
* into the response next-intl produces (the two run in sequence; do not build
|
||||
* two independent NextResponse instances).
|
||||
*/
|
||||
export async function updateSupabaseSession(request: NextRequest) {
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
serverEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||
serverEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
|
||||
{
|
||||
db: { schema: SUPABASE_SCHEMA },
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
request.cookies.set(name, value);
|
||||
response.cookies.set(name, value, options);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await supabase.auth.getUser();
|
||||
} catch {
|
||||
// Transient Supabase Auth outage: keep stale cookies; next request retries.
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "server-only";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Receiver } from "@upstash/qstash";
|
||||
import { serverEnv, redisKeyPrefix } from "@/lib/env/server";
|
||||
|
||||
const RATE_LIMIT_NS = "ratelimit";
|
||||
const CACHE_NS = "cache";
|
||||
|
||||
type Json = string | number | boolean | null | { [k: string]: Json } | Json[];
|
||||
|
||||
const redis = new Redis({
|
||||
url: serverEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: serverEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
function withPrefix(ns: string, key: string) {
|
||||
if (!key || key.includes(" ")) {
|
||||
throw new Error(`Invalid cache key: ${JSON.stringify(key)}`);
|
||||
}
|
||||
return `${redisKeyPrefix}:${ns}:${key}`;
|
||||
}
|
||||
|
||||
export const cache = {
|
||||
async get<T extends Json>(key: string): Promise<T | null> {
|
||||
return redis.get<T>(withPrefix(CACHE_NS, key));
|
||||
},
|
||||
async set(key: string, value: Json, opts?: { ex?: number }) {
|
||||
if (opts?.ex !== undefined) {
|
||||
return redis.set(withPrefix(CACHE_NS, key), value, { ex: opts.ex });
|
||||
}
|
||||
return redis.set(withPrefix(CACHE_NS, key), value);
|
||||
},
|
||||
async del(key: string, ...rest: string[]) {
|
||||
const keys = [key, ...rest];
|
||||
return redis.del(...keys.map((k) => withPrefix(CACHE_NS, k)));
|
||||
},
|
||||
/**
|
||||
* Iterate the BSK cache namespace. `matchSuffix` is appended to the
|
||||
* `bsk:{env}:cache:` prefix; keep it as narrow as possible — `"*"` sweeps
|
||||
* the entire BSK cache and should only be used in admin tools.
|
||||
*/
|
||||
async scan(matchSuffix: string, cursor: string | number = 0) {
|
||||
return redis.scan(cursor, {
|
||||
match: withPrefix(CACHE_NS, matchSuffix),
|
||||
count: 100,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a sliding-window rate limiter scoped to the BSK app + env.
|
||||
* `name` is the bucket label (e.g. "login", "queue-pickup"); it's appended
|
||||
* after the bsk:{env}:ratelimit prefix and used to namespace the bucket.
|
||||
*/
|
||||
export function createRateLimiter(name: string, requests: number, windowSeconds: number) {
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
throw new Error(`Rate-limiter name must be kebab-case alphanumerics: ${name}`);
|
||||
}
|
||||
return new Ratelimit({
|
||||
redis,
|
||||
prefix: `${redisKeyPrefix}:${RATE_LIMIT_NS}:${name}`,
|
||||
limiter: Ratelimit.slidingWindow(requests, `${windowSeconds} s`),
|
||||
analytics: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QStash signature verifier. Wraps the current + next signing keys so rotation
|
||||
* doesn't require code changes. Returns null when signing keys are not configured
|
||||
* (preview / dev environments without QStash provisioned).
|
||||
*/
|
||||
export function getQStashReceiver(): Receiver | null {
|
||||
if (!serverEnv.QSTASH_CURRENT_SIGNING_KEY || !serverEnv.QSTASH_NEXT_SIGNING_KEY) {
|
||||
return null;
|
||||
}
|
||||
return new Receiver({
|
||||
currentSigningKey: serverEnv.QSTASH_CURRENT_SIGNING_KEY,
|
||||
nextSigningKey: serverEnv.QSTASH_NEXT_SIGNING_KEY,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "BSK — Clinic Management System",
|
||||
"subtitle": "Educational Next.js rewrite of lds217/BSK.",
|
||||
"status": "Phase {phase} — Foundation."
|
||||
},
|
||||
"errors": {
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundBody": "The path you requested does not exist."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"home": {
|
||||
"title": "BSK — Hệ thống quản lý phòng khám",
|
||||
"subtitle": "Bản viết lại Next.js cho mục đích học tập của lds217/BSK.",
|
||||
"status": "Giai đoạn {phase} — Khung nền."
|
||||
},
|
||||
"errors": {
|
||||
"notFoundTitle": "Không tìm thấy trang",
|
||||
"notFoundBody": "Đường dẫn bạn truy cập không tồn tại."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
typedRoutes: true,
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "bsk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@supabase/ssr": "^0.10.3",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@upstash/qstash": "^2.11.0",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next": "^16.2.6",
|
||||
"next-intl": "^4.12.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
Generated
+5322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,8 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", ".next/cache", ".next/dev/cache"]
|
||||
}
|
||||
Reference in New Issue
Block a user