diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d0a1b..fcbabfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: node-version: '22' cache: npm - run: npm ci + - run: npx prisma generate - run: npm run lint - run: npm run typecheck - run: npm run test diff --git a/.gitignore b/.gitignore index 9148ad5..8b6bf90 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,11 @@ playwright-report/ .env.local .env.*.local -# database dumps & backups +# database dumps & backups — but never the Prisma migrations, which are source backups/ *.sql *.dump +!prisma/migrations/**/*.sql # generated preview artifacts .preview/ @@ -41,3 +42,6 @@ yarn-error.log* # typescript incremental build info *.tsbuildinfo + +# prisma generated client +lib/generated/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f054d20 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +name: ita-ito-horaires + +x-restart: &restart + restart: unless-stopped + +# No privilege escalation inside the containers (setuid binaries are ignored). +x-hardening: &hardening + security_opt: + - no-new-privileges:true + +services: + db: + image: postgres:16-alpine + <<: [*restart, *hardening] + environment: + POSTGRES_DB: ${POSTGRES_DB:-horaires} + POSTGRES_USER: ${POSTGRES_USER:-horaires} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "${DB_BIND:-127.0.0.1}:${DB_PORT:-55433}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + pgdata: diff --git a/eslint.config.mjs b/eslint.config.mjs index fd9a950..b09e305 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,7 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { - ignores: ['.next/**', 'node_modules/**', 'coverage/**', 'next-env.d.ts', 'prisma/generated/**'], + ignores: ['.next/**', 'node_modules/**', 'coverage/**', 'next-env.d.ts', 'lib/generated/**'], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/lib/db.ts b/lib/db.ts new file mode 100644 index 0000000..f0e2c20 --- /dev/null +++ b/lib/db.ts @@ -0,0 +1,20 @@ +import { PrismaPg } from '@prisma/adapter-pg'; + +import { PrismaClient } from '@/lib/generated/prisma/client'; + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL is not set'); +} + +// Next.js hot-reloads modules in development; without this the dev server +// would open a new pool on every edit until Postgres refuses connections. +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +export const prisma = + globalForPrisma.prisma ?? new PrismaClient({ adapter: new PrismaPg({ connectionString }) }); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma; +} diff --git a/package-lock.json b/package-lock.json index cc29c35..8e08996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,16 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/source-serif-4": "^5.3.0", + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "@resvg/resvg-js": "^2.6.2", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", + "dotenv": "^18.0.1", "next": "^16.3.5", "next-auth": "^5.0.0-beta.32", "node-cron": "^4.6.0", + "pg": "^8.23.0", "react": "^19.3.0", "react-dom": "^19.3.0", "satori": "^0.33.4", @@ -28,6 +31,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/node": "^26.6.2", "@types/node-cron": "^3.0.11", + "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitest/coverage-v8": "^4.1.11", @@ -2127,6 +2131,18 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", + "integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.10.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -2174,7 +2190,6 @@ "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz", "integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -2208,6 +2223,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz", + "integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.10.0" + } + }, "node_modules/@prisma/engines": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz", @@ -3446,7 +3470,6 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -3459,6 +3482,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -4800,6 +4834,19 @@ } } }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -5434,11 +5481,13 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "devOptional": true, + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.1.tgz", + "integrity": "sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, @@ -8824,6 +8873,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8921,6 +9068,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -9705,6 +9891,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -10379,7 +10574,6 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, "license": "MIT" }, "node_modules/unicode-trie": { @@ -11101,6 +11295,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 33289fd..d7e8158 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/source-serif-4": "^5.3.0", + "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", "@resvg/resvg-js": "^2.6.2", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", + "dotenv": "^18.0.1", "next": "^16.3.5", "next-auth": "^5.0.0-beta.32", "node-cron": "^4.6.0", + "pg": "^8.23.0", "react": "^19.3.0", "react-dom": "^19.3.0", "satori": "^0.33.4", @@ -40,6 +43,7 @@ "@tailwindcss/postcss": "^4.3.3", "@types/node": "^26.6.2", "@types/node-cron": "^3.0.11", + "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitest/coverage-v8": "^4.1.11", diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..45702d5 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,15 @@ +import 'dotenv/config'; +import path from 'node:path'; +import { defineConfig, env } from 'prisma/config'; + +// Prisma 7 keeps the connection URL out of the schema file, so it lives here. +export default defineConfig({ + schema: path.join('prisma', 'schema.prisma'), + migrations: { + path: path.join('prisma', 'migrations'), + seed: 'tsx prisma/seed.ts', + }, + datasource: { + url: env('DATABASE_URL'), + }, +}); diff --git a/prisma/migrations/20260920153223_init/migration.sql b/prisma/migrations/20260920153223_init/migration.sql new file mode 100644 index 0000000..bd453b6 --- /dev/null +++ b/prisma/migrations/20260920153223_init/migration.sql @@ -0,0 +1,210 @@ +-- CreateEnum +CREATE TYPE "ExceptionReason" AS ENUM ('TEMPORARY', 'HOLIDAY', 'VACATION', 'SPECIAL_EVENT'); + +-- CreateEnum +CREATE TYPE "ExceptionSource" AS ENUM ('MANUAL', 'HOLIDAY_API'); + +-- CreateEnum +CREATE TYPE "TranslationStatus" AS ENUM ('PENDING', 'DONE', 'MANUAL', 'ERROR'); + +-- CreateEnum +CREATE TYPE "DeviceLogLevel" AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR'); + +-- CreateTable +CREATE TABLE "settings" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "shopName" TEXT NOT NULL DEFAULT 'ITA ITO', + "timezone" TEXT NOT NULL DEFAULT 'Europe/Zurich', + "countryIsoCode" TEXT NOT NULL DEFAULT 'CH', + "subdivisionCode" TEXT NOT NULL DEFAULT 'CH-GE', + "defaultClosedMessageFr" TEXT NOT NULL DEFAULT 'Fermé', + "defaultClosedMessageEn" TEXT NOT NULL DEFAULT 'Closed', + "refreshRateOpenSec" INTEGER NOT NULL DEFAULT 600, + "refreshRateClosedSec" INTEGER NOT NULL DEFAULT 7200, + "imageFormat" TEXT NOT NULL DEFAULT 'bmp', + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "settings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "weekly_schedules" ( + "id" TEXT NOT NULL, + "dayOfWeek" INTEGER NOT NULL, + "isClosed" BOOLEAN NOT NULL DEFAULT false, + "slots" JSONB NOT NULL DEFAULT '[]', + + CONSTRAINT "weekly_schedules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "schedule_exceptions" ( + "id" TEXT NOT NULL, + "date" DATE NOT NULL, + "isClosed" BOOLEAN NOT NULL DEFAULT true, + "slots" JSONB, + "reason" "ExceptionReason" NOT NULL DEFAULT 'TEMPORARY', + "noteFr" TEXT, + "noteEn" TEXT, + "source" "ExceptionSource" NOT NULL DEFAULT 'MANUAL', + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "schedule_exceptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "vacation_periods" ( + "id" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "labelFr" TEXT NOT NULL, + "labelEn" TEXT, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "vacation_periods_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public_holidays" ( + "id" TEXT NOT NULL, + "date" DATE NOT NULL, + "nameFr" TEXT NOT NULL, + "nameEn" TEXT NOT NULL, + "nationwide" BOOLEAN NOT NULL DEFAULT true, + "subdivisionCode" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'openholidaysapi.org', + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isAutoClosed" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "public_holidays_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" TEXT NOT NULL, + "textFr" TEXT NOT NULL, + "textEn" TEXT, + "translationStatus" "TranslationStatus" NOT NULL DEFAULT 'PENDING', + "startsAt" TIMESTAMP(3), + "endsAt" TIMESTAMP(3), + "priority" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "translation_cache" ( + "id" TEXT NOT NULL, + "sourceHash" TEXT NOT NULL, + "targetLang" TEXT NOT NULL DEFAULT 'en', + "sourceText" TEXT NOT NULL, + "translatedText" TEXT NOT NULL, + "model" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "translation_cache_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" TEXT NOT NULL, + "userEmail" TEXT NOT NULL, + "action" TEXT NOT NULL, + "entity" TEXT NOT NULL, + "entityId" TEXT, + "diff" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "devices" ( + "id" TEXT NOT NULL, + "macAddress" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "apiKeyHash" TEXT NOT NULL, + "label" TEXT NOT NULL DEFAULT 'Vitrine', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastSeenAt" TIMESTAMP(3), + "fwVersion" TEXT, + "batteryVoltage" DOUBLE PRECISION, + "percentCharged" INTEGER, + "rssi" INTEGER, + "lastFilename" TEXT, + "lastRefreshRate" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "devices_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "device_logs" ( + "id" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "level" "DeviceLogLevel" NOT NULL DEFAULT 'INFO', + "message" TEXT NOT NULL, + "payload" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "device_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sync_states" ( + "key" TEXT NOT NULL, + "lastSuccessAt" TIMESTAMP(3), + "lastErrorAt" TIMESTAMP(3), + "lastError" TEXT, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sync_states_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "weekly_schedules_dayOfWeek_key" ON "weekly_schedules"("dayOfWeek"); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_exceptions_date_key" ON "schedule_exceptions"("date"); + +-- CreateIndex +CREATE INDEX "schedule_exceptions_date_idx" ON "schedule_exceptions"("date"); + +-- CreateIndex +CREATE INDEX "vacation_periods_startDate_endDate_idx" ON "vacation_periods"("startDate", "endDate"); + +-- CreateIndex +CREATE INDEX "public_holidays_date_idx" ON "public_holidays"("date"); + +-- CreateIndex +CREATE UNIQUE INDEX "public_holidays_date_subdivisionCode_key" ON "public_holidays"("date", "subdivisionCode"); + +-- CreateIndex +CREATE INDEX "messages_isActive_priority_idx" ON "messages"("isActive", "priority"); + +-- CreateIndex +CREATE UNIQUE INDEX "translation_cache_sourceHash_targetLang_model_key" ON "translation_cache"("sourceHash", "targetLang", "model"); + +-- CreateIndex +CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "devices_macAddress_key" ON "devices"("macAddress"); + +-- CreateIndex +CREATE UNIQUE INDEX "devices_friendlyId_key" ON "devices"("friendlyId"); + +-- CreateIndex +CREATE INDEX "device_logs_deviceId_createdAt_idx" ON "device_logs"("deviceId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "device_logs" ADD CONSTRAINT "device_logs_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "devices"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..2d81dab --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,233 @@ +// ITA ITO — opening-hours display. +// +// Two rules shape this schema and are worth stating once: +// +// 1. Opening times are WALL-CLOCK strings ("HH:mm") in the shop's timezone, +// never instants. Nothing here is stored in UTC, so the March and October +// daylight-saving switches are non-events rather than edge cases. +// 2. VacationPeriod is a source of truth, not a generator. It is never +// materialised into ScheduleException rows, so it can never overwrite a +// manual exception and editing a period never leaves orphans behind. + +generator client { + provider = "prisma-client" + output = "../lib/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +/// Why a day is not on its usual schedule. +enum ExceptionReason { + TEMPORARY + HOLIDAY + VACATION + SPECIAL_EVENT +} + +/// Who created an exception. HOLIDAY_API rows may be rewritten by the daily +/// sync; MANUAL rows never are. +enum ExceptionSource { + MANUAL + HOLIDAY_API +} + +enum TranslationStatus { + PENDING + DONE + MANUAL + ERROR +} + +enum DeviceLogLevel { + DEBUG + INFO + WARN + ERROR +} + +/// Single-row table. `id` is pinned so upserts never race into a second row. +model Settings { + id String @id @default("singleton") + shopName String @default("ITA ITO") + timezone String @default("Europe/Zurich") + countryIsoCode String @default("CH") + subdivisionCode String @default("CH-GE") + defaultClosedMessageFr String @default("Fermé") + defaultClosedMessageEn String @default("Closed") + /// Device wake interval while the shop is open, or about to change state. + refreshRateOpenSec Int @default(600) + /// Device wake interval overnight and on closed days. + refreshRateClosedSec Int @default(7200) + imageFormat String @default("bmp") + updatedAt DateTime @updatedAt + + @@map("settings") +} + +/// The reference week. 0 = Sunday .. 6 = Saturday, matching JS getDay(). +model WeeklySchedule { + id String @id @default(cuid()) + dayOfWeek Int @unique + isClosed Boolean @default(false) + /// [{"open":"10:00","close":"13:00"}, ...] — at most 3 slots, validated in + /// lib/schedule/validate.ts before it ever reaches the database. + slots Json @default("[]") + + @@map("weekly_schedules") +} + +/// A single dated departure from the reference week. Covers both "today is +/// different" and "the 24th of December will be different". +model ScheduleException { + id String @id @default(cuid()) + date DateTime @unique @db.Date + isClosed Boolean @default(true) + slots Json? + reason ExceptionReason @default(TEMPORARY) + noteFr String? + noteEn String? + source ExceptionSource @default(MANUAL) + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([date]) + @@map("schedule_exceptions") +} + +/// A closure spanning several days. Read directly by the resolver at priority +/// rank 2; deliberately never expanded into ScheduleException rows. +model VacationPeriod { + id String @id @default(cuid()) + startDate DateTime @db.Date + endDate DateTime @db.Date + labelFr String + labelEn String? + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([startDate, endDate]) + @@map("vacation_periods") +} + +/// Local cache of the rolling 12-month holiday calendar. `isAutoClosed` is the +/// shop's decision, not the API's: some public holidays are trading days. +model PublicHoliday { + id String @id @default(cuid()) + date DateTime @db.Date + nameFr String + nameEn String + nationwide Boolean @default(true) + subdivisionCode String + source String @default("openholidaysapi.org") + syncedAt DateTime @default(now()) + isAutoClosed Boolean @default(true) + + @@unique([date, subdivisionCode]) + @@index([date]) + @@map("public_holidays") +} + +/// Free-text notice shown at the bottom of the screen, French then English. +model Message { + id String @id @default(cuid()) + textFr String + textEn String? + translationStatus TranslationStatus @default(PENDING) + startsAt DateTime? + endsAt DateTime? + priority Int @default(0) + isActive Boolean @default(true) + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive, priority]) + @@map("messages") +} + +/// Keyed by sha256 of the French source, so the same text is never paid for +/// twice. The model is part of the key: a model change invalidates nothing, +/// it simply misses and re-translates. +model TranslationCache { + id String @id @default(cuid()) + sourceHash String + targetLang String @default("en") + sourceText String + translatedText String + model String + createdAt DateTime @default(now()) + + @@unique([sourceHash, targetLang, model]) + @@map("translation_cache") +} + +model AuditLog { + id String @id @default(cuid()) + userEmail String + action String + entity String + entityId String? + diff Json? + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("audit_logs") +} + +/// One e-ink panel. Registered on its first /api/setup call; the access token +/// is only ever stored as a SHA-256 digest. +model Device { + id String @id @default(cuid()) + macAddress String @unique + friendlyId String @unique + apiKeyHash String + label String @default("Vitrine") + isActive Boolean @default(true) + lastSeenAt DateTime? + fwVersion String? + batteryVoltage Float? + percentCharged Int? + rssi Int? + /// Content hash of the image last handed to the device. The firmware skips + /// the redraw when the filename is unchanged, which is where the battery + /// life actually comes from. + lastFilename String? + lastRefreshRate Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + logs DeviceLog[] + + @@map("devices") +} + +/// Firmware-side logs, posted to /api/log. Purged after 30 days by the daily job. +model DeviceLog { + id String @id @default(cuid()) + deviceId String + level DeviceLogLevel @default(INFO) + message String + payload Json? + createdAt DateTime @default(now()) + + device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + @@index([deviceId, createdAt]) + @@map("device_logs") +} + +/// Last outcome of each background sync, so the UI can show "last successful +/// sync" instead of failing silently. +model SyncState { + key String @id + lastSuccessAt DateTime? + lastErrorAt DateTime? + lastError String? + updatedAt DateTime @updatedAt + + @@map("sync_states") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..2542269 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,75 @@ +import 'dotenv/config'; + +import { PrismaPg } from '@prisma/adapter-pg'; + +import { PrismaClient } from '../lib/generated/prisma/client'; + +const connectionString = process.env.DATABASE_URL; + +if (!connectionString) { + throw new Error('DATABASE_URL is not set'); +} + +const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }); + +/** + * Placeholder reference week, to be corrected from /admin/horaires on first use. + * Wednesday carries a lunch break on purpose: it exercises the multi-slot path + * of the resolver from the very first run, rather than leaving it untested + * against real data until someone happens to need it. + * + * dayOfWeek follows JS getDay(): 0 = Sunday .. 6 = Saturday. + */ +const WEEK: { dayOfWeek: number; isClosed: boolean; slots: { open: string; close: string }[] }[] = [ + { dayOfWeek: 0, isClosed: true, slots: [] }, + { dayOfWeek: 1, isClosed: true, slots: [] }, + { dayOfWeek: 2, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { + dayOfWeek: 3, + isClosed: false, + slots: [ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, + ], + }, + { dayOfWeek: 4, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { dayOfWeek: 5, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, + { dayOfWeek: 6, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] }, +]; + +async function main(): Promise { + // Every write below is an upsert: running the seed twice must leave the + // database exactly as running it once did. + const settings = await prisma.settings.upsert({ + where: { id: 'singleton' }, + update: {}, + create: { id: 'singleton' }, + }); + + for (const day of WEEK) { + await prisma.weeklySchedule.upsert({ + where: { dayOfWeek: day.dayOfWeek }, + update: {}, + create: { dayOfWeek: day.dayOfWeek, isClosed: day.isClosed, slots: day.slots }, + }); + } + + await prisma.syncState.upsert({ + where: { key: 'holidays' }, + update: {}, + create: { key: 'holidays' }, + }); + + const openDays = WEEK.filter((day) => !day.isClosed).length; + console.log(`Seeded settings for "${settings.shopName}" and ${openDays} open days.`); + console.log('Adjust the reference week from /admin/horaires.'); +} + +main() + .catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => { + void prisma.$disconnect(); + });