Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,52 @@ jobs:
- name: npm audit (prod deps)
run: npm run audit:prod

e2e:
runs-on: ubuntu-latest
needs: verify
env:
DATABASE_URL: postgresql://test:test@localhost:5432/aetch_test
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: test-secret-32-chars-minimum-abcdef
GOOGLE_CLIENT_ID: test
GOOGLE_CLIENT_SECRET: test
CI: 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx prisma generate
- run: npm run build
- name: playwright (smoke + guards, not visual)
run: npx playwright test --grep-invert "visual regression"

lighthouse:
runs-on: ubuntu-latest
needs: verify
if: github.event_name == 'pull_request'
env:
DATABASE_URL: postgresql://test:test@localhost:5432/aetch_test
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: test-secret-32-chars-minimum-abcdef
GOOGLE_CLIENT_ID: test
GOOGLE_CLIENT_SECRET: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: lighthouse ci
run: |
npm install --no-save @lhci/cli@0.13
npx lhci autorun --config=./lighthouserc.json || echo "lighthouse warnings only"

gitleaks:
runs-on: ubuntu-latest
steps:
Expand Down
15 changes: 15 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

# block secrets in staged content
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --redact --config .gitleaks.toml || {
echo "✗ gitleaks found possible secret. Stash or amend, then commit."
exit 1
}
else
echo "(gitleaks not installed — skipping local secret scan; CI still runs it)"
fi

# format + lint only changed files
npx lint-staged
4 changes: 4 additions & 0 deletions .lintstagedrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"],
"*.{md,json,yml,yaml,css}": ["prettier --write"]
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
## aetch

alright, so a freelance client recently asked me, "what if someone could see a tattoo on their body before actually getting it done?"
that one question turned into this. aetch is a tattoo platform: discovery, artist portfolios, bookings, social, ai generation, ar preview, all in one place. it started as a quick idea and kept growing because the problem space kept growing.
that one question turned into this. aetch is a tattoo platform: discovery, artist portfolios, bookings, social, ai generation, ar preview, all in one place. it started as a quick idea and kept growing because the problem space kept growing.

---

Expand Down
626 changes: 313 additions & 313 deletions docs/database-schema.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/migration-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ How we move schema changes from dev to production safely.

## Production deploy

Migrations are applied *before* the app binary goes live.
Migrations are applied _before_ the app binary goes live.

**On Vercel** we run `prisma migrate deploy` in a dedicated step (either a GitHub Actions job before the Vercel deploy, or via a release phase script):

Expand All @@ -27,7 +27,7 @@ Migrations are applied *before* the app binary goes live.

1. **Additive first.** New columns default to nullable or have defaults. Never drop or rename in the same migration as code that reads the old name.
2. **Two-phase destructive changes.** Deploy code that writes both old and new. Backfill. Deploy code that reads only new. Drop old column in a later release.
3. **No long-running migrations in the deploy step.** For big backfills, run them as a separate job (SQL or a script) *after* the migration that adds the column but *before* code depends on it.
3. **No long-running migrations in the deploy step.** For big backfills, run them as a separate job (SQL or a script) _after_ the migration that adds the column but _before_ code depends on it.
4. **Lock-sensitive operations** (e.g. `ALTER TABLE ... NOT NULL`, index creation on large tables) — prefer `CREATE INDEX CONCURRENTLY` and dual-phase constraint additions.

## Prisma + connection pooling
Expand All @@ -40,7 +40,7 @@ Migrations are applied *before* the app binary goes live.

- We do not auto-rollback migrations. If a migration goes bad:
1. Roll the app back to the previous deploy (Vercel instant rollback)
2. Write a *new* forward-only migration that reverses the damage
2. Write a _new_ forward-only migration that reverses the damage
3. Never run `prisma migrate reset` in production

## Release checklist
Expand Down
23 changes: 23 additions & 0 deletions docs/pre-commit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Local pre-commit hook

Husky + lint-staged + gitleaks run before every commit.

## Install

```bash
npm install --save-dev husky lint-staged
brew install gitleaks
npm run prepare
chmod +x .husky/pre-commit
```

The `prepare` script wires husky into `.git/hooks/`.

## What runs

1. `gitleaks protect --staged --redact --config .gitleaks.toml` — fails on any secret-looking blob in staged content.
2. `lint-staged` — runs `prettier --write` + `eslint --fix` on the changed files only (config: `.lintstagedrc.json`).

## Override (rare)

Skip with `git commit --no-verify` only when the failure is a known false positive — never to bypass real findings.
33 changes: 19 additions & 14 deletions docs/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,41 +49,45 @@ src/

## Main Services

| Service | Responsibility |
|---------|---------------|
| `tattoo-service` | CRUD, search, likes, saves, trending |
| `post-service` | Social feed, posts, likes, trending |
| `artist-service` | Profiles, search, availability, suggestions |
| `shop-service` | Shop profiles, artist membership, gallery |
| `booking-service` | Booking lifecycle, status transitions |
| `message-service` | Conversations, messages, unread counts |
| `notification-service` | Event notifications, mark-read |
| `user-service` | Onboarding, roles, profile management |
| `follow-service` | Follow/unfollow, follower lists |
| `comment-service` | Post comments, replies |
| `ai-service` | AI tattoo generation, history |
| `ar-preview-service` | AR preview save/retrieve |
| Service | Responsibility |
| ---------------------- | ------------------------------------------- |
| `tattoo-service` | CRUD, search, likes, saves, trending |
| `post-service` | Social feed, posts, likes, trending |
| `artist-service` | Profiles, search, availability, suggestions |
| `shop-service` | Shop profiles, artist membership, gallery |
| `booking-service` | Booking lifecycle, status transitions |
| `message-service` | Conversations, messages, unread counts |
| `notification-service` | Event notifications, mark-read |
| `user-service` | Onboarding, roles, profile management |
| `follow-service` | Follow/unfollow, follower lists |
| `comment-service` | Post comments, replies |
| `ai-service` | AI tattoo generation, history |
| `ar-preview-service` | AR preview save/retrieve |

## Data Flow

### Authentication

```
Browser → NextAuth → Google OAuth / Magic Link → Session Cookie → Auth Guard
```

### API Request Pipeline

```
Request → Rate Limiter → Auth Guard → Zod Validation → Service → Prisma → Response
```

### Image Upload Pipeline

```
Upload → MIME Validation → Size Check → Sharp Processing → S3/R2 Storage
↓
Full (1920px) + Thumbnail (300px) + Blur Placeholder (16px)
```

### Notification Flow

```
User Action → API Route → Service (main logic) → Notification Service (async, non-blocking)
```
Expand Down Expand Up @@ -126,6 +130,7 @@ See `.env.example` for all required variables. Key groups:
PostgreSQL with 23 tables and 7 enums. See `docs/database-schema.md` for full schema documentation.

Key relationships:

- User → Artist (1:1 optional)
- Artist → Tattoos (1:many)
- Artist → Shop (many:1 optional)
Expand Down
17 changes: 6 additions & 11 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';

const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
{
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
]);
Expand Down
16 changes: 15 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ const config: Config = {
'src/lib/api-error.ts',
'src/lib/feature-flags.ts',
'src/lib/env.ts',
'src/lib/totp.ts',
'src/lib/signed-upload.ts',
'src/lib/av-scan.ts',
'src/lib/sentry.ts',
'src/backend/middleware/request-log.ts',
'src/backend/services/booking-service.ts',
'src/backend/services/tattoo-service.ts',
'src/backend/services/post-service.ts',
'src/backend/services/comment-service.ts',
'src/backend/services/follow-service.ts',
'src/backend/services/notification-service.ts',
'src/backend/services/ar-preview-service.ts',
'src/backend/services/audit-log-service.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
Expand All @@ -28,7 +38,11 @@ const config: Config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['<rootDir>/tests/unit/**/*.test.ts', '<rootDir>/tests/services/**/*.test.ts'],
testMatch: [
'<rootDir>/tests/unit/**/*.test.ts',
'<rootDir>/tests/unit/**/*.test.tsx',
'<rootDir>/tests/services/**/*.test.ts',
],
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/tests/e2e/'],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.jest.json' }],
Expand Down
33 changes: 33 additions & 0 deletions lighthouserc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run start",
"startServerReadyPattern": "Ready in",
"url": [
"http://localhost:3000/",
"http://localhost:3000/login",
"http://localhost:3000/register"
],
"numberOfRuns": 1,
"settings": {
"preset": "desktop",
"chromeFlags": "--no-sandbox --headless=new"
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.8 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.9 }],
"categories:seo": ["warn", { "minScore": 0.9 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 2000 }],
"largest-contentful-paint": ["warn", { "maxNumericValue": 3000 }],
"cumulative-layout-shift": ["warn", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["warn", { "maxNumericValue": 300 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
32 changes: 2 additions & 30 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,9 @@
import type { NextConfig } from 'next';

const isProd = process.env.NODE_ENV === 'production';

// strict csp in prod, relaxed for dev HMR
const csp = isProd
? [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https: wss:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
'upgrade-insecure-requests',
].join('; ')
: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src 'self' https: wss: ws:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join('; ');
// csp is now nonce-based and emitted from src/proxy.ts per-request.
// keep static, non-nonce headers here so they apply everywhere (including api routes).

const securityHeaders = [
{ key: 'Content-Security-Policy', value: csp },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium",
"ci:check": "bash scripts/ci-check.sh",
"audit:prod": "npm audit --omit=dev --audit-level=high"
"audit:prod": "npm audit --omit=dev --audit-level=high",
"prepare": "husky || true",
"lint:staged": "lint-staged"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
Expand Down
2 changes: 1 addition & 1 deletion postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
'@tailwindcss/postcss': {},
},
};

Expand Down
28 changes: 28 additions & 0 deletions prisma/fixtures/artists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const fixtureArtists = [
{
id: 'fix_artist_1',
userId: 'fix_user_artist1',
displayName: 'Maya Inkwell',
slug: 'inkwell',
bio: 'Japanese-inspired large scale work.',
specialties: ['JAPANESE', 'BLACKWORK'],
hourlyRate: 180,
currency: 'USD',
location: 'Brooklyn, NY',
verified: true,
},
{
id: 'fix_artist_2',
userId: 'fix_user_artist2',
displayName: 'Sam Fineline',
slug: 'finelineco',
bio: 'Fine line micro-tattoos.',
specialties: ['FINE_LINE', 'MINIMALIST'],
hourlyRate: 150,
currency: 'USD',
location: 'Austin, TX',
verified: false,
},
];

export type FixtureArtist = (typeof fixtureArtists)[number];
5 changes: 5 additions & 0 deletions prisma/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { fixtureUsers } from './users';
export { fixtureArtists } from './artists';
export { fixtureShops } from './shops';
export { fixtureTattoos } from './tattoos';
export { fixturePosts } from './posts';
Loading
Loading