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:
2026-05-25 10:58:04 +07:00
parent cbb232a92f
commit b88147059e
36 changed files with 6176 additions and 1 deletions
+17
View File
@@ -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=
+50
View File
@@ -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
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
verify-deps-before-run=false
ignored-build-scripts-status-warn-only=true
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.vercel
pnpm-lock.yaml
*.md
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
+127
View File
@@ -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): …`).
+3 -1
View File
@@ -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.
+47
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+37
View File
@@ -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);
}
+8
View File
@@ -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;
}
+20
View File
@@ -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>
);
}
+21
View File
@@ -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"
}
+20
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
+14
View File
@@ -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",
};
});
+9
View File
@@ -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];
+25
View File
@@ -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;
+34
View File
@@ -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;
+18
View File
@@ -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,
},
});
}
+12
View File
@@ -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 },
},
);
}
+34
View File
@@ -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.
}
},
},
},
);
}
+44
View File
@@ -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;
}
+81
View File
@@ -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,
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+11
View File
@@ -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."
}
}
+11
View File
@@ -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."
}
}
+11
View File
@@ -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);
+53
View File
@@ -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"
}
}
+5322
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
"@parcel/watcher": true
"@swc/core": true
sharp: true
unrs-resolver: true
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+8
View File
@@ -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|.*\\..*).*)"],
};
+37
View File
@@ -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"]
}