chore: bootstrap monorepo workspace and shared schemas

This commit is contained in:
Anton
2026-04-23 16:03:34 +03:00
commit 5f6a551b6c
15 changed files with 308 additions and 0 deletions

40
.env.example Normal file
View File

@@ -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

35
.gitignore vendored Normal file
View File

@@ -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

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
build
.pnpm-store
apps/backend/prisma/migrations
apps/backend/uploads
pnpm-lock.yaml

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}

23
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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<typeof loginSchema>;
export const authUserSchema = z.object({
id: z.string(),
username: z.string(),
slug: z.string(),
displayName: z.string(),
});
export type AuthUser = z.infer<typeof authUserSchema>;

View File

@@ -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';

View File

@@ -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<typeof profileSchema>;
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<typeof updateProfileSchema>;

View File

@@ -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<typeof publicProfileSchema>;
export const markSeenSchema = z.object({
wishIds: z.array(z.string().cuid2().or(z.string())).min(1).max(500),
});
export type MarkSeenInput = z.infer<typeof markSeenSchema>;

View File

@@ -0,0 +1,9 @@
export interface ApiError {
error: string;
message: string;
details?: unknown;
}
export interface VersionInfo {
backend: string;
}

View File

@@ -0,0 +1,61 @@
import { z } from 'zod';
export const wishStatus = z.enum(['ACTIVE', 'ARCHIVED', 'COMPLETED', 'DELETED']);
export type WishStatus = z.infer<typeof wishStatus>;
export const imageSource = z.enum(['DEFAULT', 'OG', 'UPLOADED']);
export type ImageSource = z.infer<typeof imageSource>;
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<typeof wishSchema>;
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<typeof createWishSchema>;
export const updateWishSchema = createWishSchema.partial();
export type UpdateWishInput = z.infer<typeof updateWishSchema>;
export const wishStatusQuery = z
.enum(['active', 'archived', 'completed', 'deleted'])
.default('active');
export const NEW_BADGE_DAYS = 5;
export const TRASH_RETENTION_DAYS = 30;

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": true
},
"include": ["src/**/*"]
}

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

21
tsconfig.base.json Normal file
View File

@@ -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
}
}