From 5f6a551b6c91467397853c97375e659786ff10c4 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 23 Apr 2026 16:03:34 +0300 Subject: [PATCH] chore: bootstrap monorepo workspace and shared schemas --- .env.example | 40 ++++++++++++++++++ .gitignore | 35 +++++++++++++++ .prettierignore | 7 +++ .prettierrc.json | 9 ++++ package.json | 23 ++++++++++ packages/shared/package.json | 23 ++++++++++ packages/shared/src/auth.schema.ts | 17 ++++++++ packages/shared/src/index.ts | 5 +++ packages/shared/src/profile.schema.ts | 30 +++++++++++++ packages/shared/src/public.schema.ts | 16 +++++++ packages/shared/src/types.ts | 9 ++++ packages/shared/src/wish.schema.ts | 61 +++++++++++++++++++++++++++ packages/shared/tsconfig.json | 9 ++++ pnpm-workspace.yaml | 3 ++ tsconfig.base.json | 21 +++++++++ 15 files changed, 308 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/auth.schema.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/profile.schema.ts create mode 100644 packages/shared/src/public.schema.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/src/wish.schema.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..778aced --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# ========================================== +# Database +# ========================================== +POSTGRES_USER=wishlist +POSTGRES_PASSWORD=change_me +POSTGRES_DB=family_wishlist +# DATABASE_URL uses the docker-compose service name `postgres`. +# When running backend outside docker against docker-postgres use localhost:5432. +DATABASE_URL=postgresql://wishlist:change_me@postgres:5432/family_wishlist + +# ========================================== +# Users (two fixed accounts) +# Generate hashes with: +# pnpm hash-password "YourPlainPassword" +# The plain password is NEVER stored anywhere in the app. +# ========================================== +USER1_USERNAME=alice +USER1_PASSWORD_HASH= +USER1_SLUG=alice +USER1_DISPLAY_NAME=Alice + +USER2_USERNAME=bob +USER2_PASSWORD_HASH= +USER2_SLUG=bob +USER2_DISPLAY_NAME=Bob + +# ========================================== +# Secrets (generate: openssl rand -hex 32) +# ========================================== +JWT_SECRET= +COOKIE_SECRET= + +# ========================================== +# Runtime +# ========================================== +NODE_ENV=development +BACKEND_PORT=3000 +PUBLIC_APP_URL=http://localhost:8080 +UPLOADS_DIR=./uploads +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1866864 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# dependencies +node_modules/ +.pnpm-store/ + +# build artifacts +dist/ +build/ +.turbo/ +*.tsbuildinfo + +# env / secrets +.env +.env.local +.env.*.local + +# uploads (dev) +apps/backend/uploads/ +!apps/backend/uploads/.gitkeep + +# prisma +apps/backend/prisma/dev.db +apps/backend/prisma/migrations/dev/ + +# logs +*.log +npm-debug.log* +pnpm-debug.log* + +# editor / os +.DS_Store +Thumbs.db +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.example.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7a3af48 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +dist +build +.pnpm-store +apps/backend/prisma/migrations +apps/backend/uploads +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..f800d4e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd1bdc6 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "family-wishlist", + "private": true, + "version": "0.1.0", + "description": "Family Wishlist monorepo: Fastify + Prisma backend, React + Vite frontend", + "packageManager": "pnpm@9.12.0", + "engines": { + "node": ">=20.10.0", + "pnpm": ">=9" + }, + "scripts": { + "dev": "pnpm -r --parallel --stream run dev", + "build": "pnpm -r run build", + "typecheck": "pnpm -r run typecheck", + "lint": "pnpm -r run lint", + "format": "prettier --write .", + "hash-password": "pnpm --filter @family-wishlist/backend hash-password" + }, + "devDependencies": { + "prettier": "^3.3.3", + "typescript": "^5.6.2" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..f2ee0df --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@family-wishlist/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "echo 'shared is source-only'", + "lint": "echo 'skip'", + "dev": "echo 'shared is source-only'" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.6.2" + } +} diff --git a/packages/shared/src/auth.schema.ts b/packages/shared/src/auth.schema.ts new file mode 100644 index 0000000..a4ea9c2 --- /dev/null +++ b/packages/shared/src/auth.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + username: z.string().trim().min(3).max(64), + password: z.string().min(8).max(200), +}); + +export type LoginInput = z.infer; + +export const authUserSchema = z.object({ + id: z.string(), + username: z.string(), + slug: z.string(), + displayName: z.string(), +}); + +export type AuthUser = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..dbfde2e --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,5 @@ +export * from './auth.schema.js'; +export * from './profile.schema.js'; +export * from './wish.schema.js'; +export * from './public.schema.js'; +export * from './types.js'; diff --git a/packages/shared/src/profile.schema.ts b/packages/shared/src/profile.schema.ts new file mode 100644 index 0000000..dba7051 --- /dev/null +++ b/packages/shared/src/profile.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const slugRegex = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/; + +export const profileSchema = z.object({ + id: z.string(), + username: z.string(), + slug: z.string(), + displayName: z.string(), + bio: z.string().nullable(), + avatarUrl: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type Profile = z.infer; + +export const updateProfileSchema = z.object({ + slug: z + .string() + .trim() + .toLowerCase() + .regex(slugRegex, 'Slug must be 3-32 chars, lowercase letters, digits, hyphens') + .optional(), + displayName: z.string().trim().min(1).max(64).optional(), + bio: z.string().trim().max(500).nullable().optional(), + avatarUrl: z.string().url().nullable().optional(), +}); + +export type UpdateProfileInput = z.infer; diff --git a/packages/shared/src/public.schema.ts b/packages/shared/src/public.schema.ts new file mode 100644 index 0000000..0819763 --- /dev/null +++ b/packages/shared/src/public.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const publicProfileSchema = z.object({ + slug: z.string(), + displayName: z.string(), + bio: z.string().nullable(), + avatarUrl: z.string().nullable(), +}); + +export type PublicProfile = z.infer; + +export const markSeenSchema = z.object({ + wishIds: z.array(z.string().cuid2().or(z.string())).min(1).max(500), +}); + +export type MarkSeenInput = z.infer; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..ae15cbf --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,9 @@ +export interface ApiError { + error: string; + message: string; + details?: unknown; +} + +export interface VersionInfo { + backend: string; +} diff --git a/packages/shared/src/wish.schema.ts b/packages/shared/src/wish.schema.ts new file mode 100644 index 0000000..0c63d79 --- /dev/null +++ b/packages/shared/src/wish.schema.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +export const wishStatus = z.enum(['ACTIVE', 'ARCHIVED', 'COMPLETED', 'DELETED']); +export type WishStatus = z.infer; + +export const imageSource = z.enum(['DEFAULT', 'OG', 'UPLOADED']); +export type ImageSource = z.infer; + +export const wishSchema = z.object({ + id: z.string(), + title: z.string(), + price: z.string().nullable(), + currency: z.string(), + url: z.string().nullable(), + comment: z.string().nullable(), + imageUrl: z.string().nullable(), + imageSource: imageSource, + status: wishStatus, + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), + completedAt: z.string().nullable(), + deletedAt: z.string().nullable(), + sourceWishId: z.string().nullable(), + isNewForGuest: z.boolean().optional(), +}); + +export type Wish = z.infer; + +export const createWishSchema = z.object({ + title: z.string().trim().min(1).max(200), + price: z + .union([ + z.string().trim().regex(/^\d+(\.\d{1,2})?$/, 'Price must be a number with up to 2 decimals'), + z.literal(''), + z.null(), + ]) + .optional() + .transform((v) => (v === '' || v == null ? null : v)), + currency: z.string().trim().length(3).toUpperCase().default('RUB'), + url: z + .union([z.string().trim().url(), z.literal(''), z.null()]) + .optional() + .transform((v) => (v === '' || v == null ? null : v)), + comment: z + .union([z.string().trim().max(2000), z.literal(''), z.null()]) + .optional() + .transform((v) => (v === '' || v == null ? null : v)), +}); + +export type CreateWishInput = z.infer; + +export const updateWishSchema = createWishSchema.partial(); +export type UpdateWishInput = z.infer; + +export const wishStatusQuery = z + .enum(['active', 'archived', 'completed', 'deleted']) + .default('active'); + +export const NEW_BADGE_DAYS = 5; +export const TRASH_RETENTION_DAYS = 30; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..14e2da5 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..d00a4ae --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": false, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "verbatimModuleSyntax": false + } +}