feat: add production deployment config

- Add docker-compose.prod.yml and Caddyfile for Caddy reverse proxy
- Add production stages to frontend Dockerfile (nginx for static files)
- Fix monorepo package exports for production builds (dist/src paths)
- Add CORS_ORIGIN env var for cross-origin config
- Add Better Auth baseURL, cookie domain, and trusted origins from env
- Use VITE_API_URL for API calls in auth-client and play route
- Add credentials: include for cross-origin fetch requests
- Remove unused users table from schema
This commit is contained in:
lila 2026-04-14 11:38:40 +02:00
parent 3f7bc4111e
commit bc38137a12
20 changed files with 421515 additions and 34 deletions

View file

@ -1,5 +1,12 @@
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/postgres
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=databasename
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

11
Caddyfile Normal file
View file

@ -0,0 +1,11 @@
lilastudy.com {
reverse_proxy web:80
}
api.lilastudy.com {
reverse_proxy api:3000
}
git.lilastudy.com {
reverse_proxy forgejo:3000
}

View file

@ -32,11 +32,13 @@ RUN pnpm --filter api build
# 5. run
FROM base AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/package.json /app/packages/shared/package.json
COPY --from=deps /app/packages/db/package.json /app/packages/db/package.json
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/packages/shared/dist /app/packages/shared/dist
COPY --from=builder /app/packages/db/dist /app/packages/db/dist
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/db/dist ./packages/db/dist
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000
CMD ["node", "dist/server.js"]
CMD ["node", "apps/api/dist/src/server.js"]

View file

@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"start": "node dist/src/server.js",
"test": "vitest"
},
"dependencies": {

View file

@ -9,7 +9,12 @@ import cors from "cors";
export function createApp() {
const app: Express = express();
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
app.use(
cors({
origin: process.env["CORS_ORIGIN"] || "http://localhost:5173",
credentials: true,
}),
);
app.all("/api/auth/*splat", toNodeHandler(auth));
app.use(express.json());
app.use("/api/v1", apiRouter);

View file

@ -4,8 +4,19 @@ import { db } from "@lila/db";
import * as schema from "@lila/db/schema";
export const auth = betterAuth({
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
advanced: {
cookiePrefix: "lila",
defaultCookieAttributes: {
...(process.env["COOKIE_DOMAIN"] && {
domain: process.env["COOKIE_DOMAIN"],
}),
secure: !!process.env["COOKIE_DOMAIN"],
sameSite: "lax" as const,
},
},
database: drizzleAdapter(db, { provider: "pg", schema }),
trustedOrigins: ["http://localhost:5173"],
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
socialProviders: {
google: {
clientId: process.env["GOOGLE_CLIENT_ID"] as string,

View file

@ -17,3 +17,20 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . ./
EXPOSE 5173
CMD ["pnpm", "--filter", "web", "dev", "--host"]
# 4. Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm install
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter shared build
RUN pnpm --filter web build
# 5. Production — just nginx serving static files
FROM nginx:alpine AS production
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

9
apps/web/nginx.conf Normal file
View file

@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: "http://localhost:3000",
baseURL: import.meta.env["VITE_API_URL"] || "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;

View file

@ -20,7 +20,7 @@ const LoginPage = () => {
onClick={() =>
signIn.social({
provider: "github",
callbackURL: "http://localhost:5173",
callbackURL: window.location.origin,
})
}
>
@ -31,7 +31,7 @@ const LoginPage = () => {
onClick={() =>
signIn.social({
provider: "google",
callbackURL: "http://localhost:5173",
callbackURL: window.location.origin,
})
}
>

View file

@ -7,6 +7,8 @@ import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() {
const API_URL = import.meta.env["VITE_API_URL"] || "";
const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@ -15,9 +17,10 @@ function Play() {
const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true);
const response = await fetch("/api/v1/game/start", {
const response = await fetch(`${API_URL}/api/v1/game/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings),
});
const data = await response.json();
@ -42,9 +45,10 @@ function Play() {
const question = gameSession.questions[currentQuestionIndex];
if (!question) return;
const response = await fetch("/api/v1/game/answer", {
const response = await fetch(`${API_URL}/api/v1/game/answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sessionId: gameSession.sessionId,
questionId: question.questionId,

91
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,91 @@
services:
caddy:
container_name: lila-caddy
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- lila-network
api:
container_name: lila-api
build:
context: .
dockerfile: ./apps/api/Dockerfile
target: runner
env_file:
- .env
restart: unless-stopped
healthcheck:
test:
["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
interval: 5s
timeout: 3s
retries: 5
depends_on:
database:
condition: service_healthy
networks:
- lila-network
web:
container_name: lila-web
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: production
args:
VITE_API_URL: https://api.lilastudy.com
restart: unless-stopped
networks:
- lila-network
database:
container_name: lila-database
image: postgres:18.3-alpine3.23
env_file:
- .env
environment:
- PGDATA=/var/lib/postgresql/data
volumes:
- lila-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- lila-network
forgejo:
container_name: lila-forgejo
image: codeberg.org/forgejo/forgejo:11
volumes:
- forgejo-data:/data
environment:
- USER_UID=1000
- USER_GID=1000
ports:
- "2222:22"
restart: unless-stopped
networks:
- lila-network
networks:
lila-network:
volumes:
lila-db:
caddy_data:
caddy_config:
forgejo-data:

View file

@ -7,6 +7,24 @@
## problems+thoughts
### docker credential helper
WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/
### vps setup
monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
### cd/ci pipeline
forgejo actions? smth else? where docker registry, also forgejo?
### postgres backups
how?
### try now option
there should be an option to try the app without an account so users can see what they would get when creating an account

View file

@ -0,0 +1 @@
DROP TABLE "users" CASCADE;

View file

@ -0,0 +1,935 @@
{
"id": "8f34bafa-cffc-4933-952f-64b46afa9c5c",
"prevId": "6455ad81-98c0-4f32-a2fa-0f99ce9ce8e5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.deck_terms": {
"name": "deck_terms",
"schema": "",
"columns": {
"deck_id": {
"name": "deck_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"deck_terms_deck_id_decks_id_fk": {
"name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms",
"tableTo": "decks",
"columnsFrom": [
"deck_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_terms_term_id_terms_id_fk": {
"name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk",
"columns": [
"deck_id",
"term_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.decks": {
"name": "decks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_language": {
"name": "source_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"validated_languages": {
"name": "validated_languages",
"type": "varchar(10)[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"type": {
"name": "type",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_decks_type": {
"name": "idx_decks_type",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_language",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_deck_name": {
"name": "unique_deck_name",
"nullsNotDistinct": false,
"columns": [
"name",
"source_language"
]
}
},
"policies": {},
"checkConstraints": {
"source_language_check": {
"name": "source_language_check",
"value": "\"decks\".\"source_language\" IN ('en', 'it')"
},
"validated_languages_check": {
"name": "validated_languages_check",
"value": "validated_languages <@ ARRAY['en', 'it']::varchar[]"
},
"validated_languages_excludes_source": {
"name": "validated_languages_excludes_source",
"value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))"
},
"deck_type_check": {
"name": "deck_type_check",
"value": "\"decks\".\"type\" IN ('grammar', 'media')"
}
},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.term_glosses": {
"name": "term_glosses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"term_glosses_term_id_terms_id_fk": {
"name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_term_gloss": {
"name": "unique_term_gloss",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code"
]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"term_glosses\".\"language_code\" IN ('en', 'it')"
}
},
"isRLSEnabled": false
},
"public.term_topics": {
"name": "term_topics",
"schema": "",
"columns": {
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"topic_id": {
"name": "topic_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"term_topics_term_id_terms_id_fk": {
"name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"term_topics_topic_id_topics_id_fk": {
"name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics",
"tableTo": "topics",
"columnsFrom": [
"topic_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk",
"columns": [
"term_id",
"topic_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.terms": {
"name": "terms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pos": {
"name": "pos",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_terms_source_pos": {
"name": "idx_terms_source_pos",
"columns": [
{
"expression": "source",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "pos",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_source_id": {
"name": "unique_source_id",
"nullsNotDistinct": false,
"columns": [
"source",
"source_id"
]
}
},
"policies": {},
"checkConstraints": {
"pos_check": {
"name": "pos_check",
"value": "\"terms\".\"pos\" IN ('noun', 'verb')"
}
},
"isRLSEnabled": false
},
"public.topics": {
"name": "topics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"topics_slug_unique": {
"name": "topics_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.translations": {
"name": "translations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cefr_level": {
"name": "cefr_level",
"type": "varchar(2)",
"primaryKey": false,
"notNull": false
},
"difficulty": {
"name": "difficulty",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_translations_lang": {
"name": "idx_translations_lang",
"columns": [
{
"expression": "language_code",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "difficulty",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "cefr_level",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"translations_term_id_terms_id_fk": {
"name": "translations_term_id_terms_id_fk",
"tableFrom": "translations",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_translations": {
"name": "unique_translations",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code",
"text"
]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"translations\".\"language_code\" IN ('en', 'it')"
},
"cefr_check": {
"name": "cefr_check",
"value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')"
},
"difficulty_check": {
"name": "difficulty_check",
"value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')"
}
},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -36,6 +36,13 @@
"when": 1775986238669,
"tag": "0004_red_annihilus",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1776154563168,
"tag": "0005_broad_mariko_yashida",
"breakpoints": true
}
]
}

View file

@ -22,7 +22,7 @@
"drizzle-kit": "^0.31.10"
},
"exports": {
".": "./src/index.ts",
"./schema": "./src/db/schema.ts"
".": "./dist/src/index.js",
"./schema": "./dist/src/db/schema.js"
}
}

View file

@ -100,20 +100,6 @@ export const translations = pgTable(
],
);
export const users = pgTable("users", {
id: uuid().primaryKey().defaultRandom(),
openauth_sub: text().unique().notNull(),
email: varchar({ length: 255 }).unique(),
display_name: varchar({ length: 100 }).unique(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
last_login_at: timestamp({ withTimezone: true }),
});
// KNOWN LIMITATION: email is nullable (GitHub users may have no public email)
// and unique, but two OAuth providers can return the same email for different
// accounts. For MVP this is acceptable since users are identified by
// openauth_sub, not email. If multi-provider login per user is added later,
// consider a separate user_emails table.
export const decks = pgTable(
"decks",
{

View file

@ -7,7 +7,7 @@
"build": "tsc"
},
"exports": {
".": "./src/index.ts"
".": "./dist/src/index.js"
},
"dependencies": {
"zod": "^4.3.6"

420377
seed.sql Normal file

File diff suppressed because it is too large Load diff