chore: bootstrap monorepo workspace and shared schemas
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal 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
35
.gitignore
vendored
Normal 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
7
.prettierignore
Normal 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
9
.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
23
package.json
Normal file
23
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/shared/src/auth.schema.ts
Normal file
17
packages/shared/src/auth.schema.ts
Normal 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>;
|
||||||
5
packages/shared/src/index.ts
Normal file
5
packages/shared/src/index.ts
Normal 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';
|
||||||
30
packages/shared/src/profile.schema.ts
Normal file
30
packages/shared/src/profile.schema.ts
Normal 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>;
|
||||||
16
packages/shared/src/public.schema.ts
Normal file
16
packages/shared/src/public.schema.ts
Normal 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>;
|
||||||
9
packages/shared/src/types.ts
Normal file
9
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
backend: string;
|
||||||
|
}
|
||||||
61
packages/shared/src/wish.schema.ts
Normal file
61
packages/shared/src/wish.schema.ts
Normal 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;
|
||||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
21
tsconfig.base.json
Normal file
21
tsconfig.base.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user