diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ee1e3..6136d59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..0e0bbc8 --- /dev/null +++ b/.husky/pre-commit @@ -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 diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..819d609 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,4 @@ +{ + "*.{ts,tsx,js,jsx}": ["prettier --write", "eslint --fix"], + "*.{md,json,yml,yaml,css}": ["prettier --write"] +} diff --git a/README.md b/README.md index 5fec624..c3bafe6 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/docs/database-schema.md b/docs/database-schema.md index 15dac66..0f72446 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -43,21 +43,21 @@ ### Account -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| type | S | Yes | tring | -| provider | S | Yes | tring | -| providerAccountId | S | Yes | tring | -| refresh_token | S | Yes | tring? @db.Text | -| access_token | S | Yes | tring? @db.Text | -| expires_at | I | Yes | nt? | -| token_type | S | Yes | tring? | -| scope | S | Yes | tring? | -| id_token | S | Yes | tring? @db.Text | -| session_state | S | Yes | tring? | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| ----------------- | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| type | S | Yes | tring | +| provider | S | Yes | tring | +| providerAccountId | S | Yes | tring | +| refresh_token | S | Yes | tring? @db.Text | +| access_token | S | Yes | tring? @db.Text | +| expires_at | I | Yes | nt? | +| token_type | S | Yes | tring? | +| scope | S | Yes | tring? | +| id_token | S | Yes | tring? @db.Text | +| session_state | S | Yes | tring? | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | **Indexes:** @@ -70,13 +70,13 @@ ### Session -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| sessionToken | S | Yes | tring @unique | -| userId | S | Yes | tring | -| expires | D | Yes | ateTime | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| ------------ | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| sessionToken | S | Yes | tring @unique | +| userId | S | Yes | tring | +| expires | D | Yes | ateTime | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | **Indexes:** @@ -88,11 +88,11 @@ ### VerificationToken -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| identifier | S | Yes | tring | -| token | S | Yes | tring @unique | -| expires | D | Yes | ateTime | +| Field | Type | Required | Attributes | +| ---------- | ---- | -------- | ------------- | +| identifier | S | Yes | tring | +| token | S | Yes | tring @unique | +| expires | D | Yes | ateTime | **Indexes:** @@ -100,36 +100,36 @@ ### User -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| name | S | Yes | tring? | -| email | S | Yes | tring? @unique | -| emailVerified | D | Yes | ateTime? | -| image | S | Yes | tring? | -| bio | S | Yes | tring? @db.Text | -| username | S | Yes | tring? @unique | -| roles | U | Yes | serRole[] @default([USER]) | -| onboardingComplete | B | Yes | oolean @default(false) | -| favoriteStyles | S | Yes | tring[] | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| accounts | A | Yes | ccount[] | -| sessions | S | Yes | ession[] | -| artist | A | Yes | rtist? | -| posts | P | Yes | ost[] | -| comments | C | Yes | omment[] | -| reviews | R | Yes | eview[] | -| bookings | B | Yes | ooking[] | -| likes | L | Yes | ike[] | -| savedTattoos | S | Yes | avedTattoo[] | -| followers | F | Yes | ollower[] @relation("Following") | -| following | F | Yes | ollower[] @relation("Followers") | -| notifications | N | Yes | otification[] | -| aiGenerations | A | Yes | IGeneration[] | -| tattooPreviews | T | Yes | attooPreview[] | -| conversations | C | Yes | onversationParticipant[] | -| sentMessages | M | Yes | essage[] | +| Field | Type | Required | Attributes | +| ------------------ | ---- | -------- | -------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| name | S | Yes | tring? | +| email | S | Yes | tring? @unique | +| emailVerified | D | Yes | ateTime? | +| image | S | Yes | tring? | +| bio | S | Yes | tring? @db.Text | +| username | S | Yes | tring? @unique | +| roles | U | Yes | serRole[] @default([USER]) | +| onboardingComplete | B | Yes | oolean @default(false) | +| favoriteStyles | S | Yes | tring[] | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| accounts | A | Yes | ccount[] | +| sessions | S | Yes | ession[] | +| artist | A | Yes | rtist? | +| posts | P | Yes | ost[] | +| comments | C | Yes | omment[] | +| reviews | R | Yes | eview[] | +| bookings | B | Yes | ooking[] | +| likes | L | Yes | ike[] | +| savedTattoos | S | Yes | avedTattoo[] | +| followers | F | Yes | ollower[] @relation("Following") | +| following | F | Yes | ollower[] @relation("Followers") | +| notifications | N | Yes | otification[] | +| aiGenerations | A | Yes | IGeneration[] | +| tattooPreviews | T | Yes | attooPreview[] | +| conversations | C | Yes | onversationParticipant[] | +| sentMessages | M | Yes | essage[] | **Indexes:** @@ -143,32 +143,32 @@ ### Artist -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring @unique | -| displayName | S | Yes | tring | -| slug | S | Yes | tring @unique | -| bio | S | Yes | tring? @db.Text | -| specialties | S | Yes | tring[] | -| hourlyRate | D | Yes | ecimal? @db.Decimal(10, 2) | -| currency | S | Yes | tring @default("USD") | -| location | S | Yes | tring? | -| latitude | F | Yes | loat? | -| longitude | F | Yes | loat? | -| website | S | Yes | tring? | -| instagram | S | Yes | tring? | -| verified | B | Yes | oolean @default(false) | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | -| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | -| shopId | S | Yes | tring? | -| tattoos | T | Yes | attoo[] | -| bookings | B | Yes | ooking[] | -| reviews | R | Yes | eview[] | -| availability | A | Yes | rtistAvailability[] | -| shopArtists | S | Yes | hopArtist[] | +| Field | Type | Required | Attributes | +| ------------ | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring @unique | +| displayName | S | Yes | tring | +| slug | S | Yes | tring @unique | +| bio | S | Yes | tring? @db.Text | +| specialties | S | Yes | tring[] | +| hourlyRate | D | Yes | ecimal? @db.Decimal(10, 2) | +| currency | S | Yes | tring @default("USD") | +| location | S | Yes | tring? | +| latitude | F | Yes | loat? | +| longitude | F | Yes | loat? | +| website | S | Yes | tring? | +| instagram | S | Yes | tring? | +| verified | B | Yes | oolean @default(false) | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | +| shopId | S | Yes | tring? | +| tattoos | T | Yes | attoo[] | +| bookings | B | Yes | ooking[] | +| reviews | R | Yes | eview[] | +| availability | A | Yes | rtistAvailability[] | +| shopArtists | S | Yes | hopArtist[] | **Indexes:** @@ -184,32 +184,32 @@ ### Shop -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| name | S | Yes | tring | -| slug | S | Yes | tring @unique | -| description | S | Yes | tring? @db.Text | -| address | S | Yes | tring? | -| city | S | Yes | tring? | -| state | S | Yes | tring? | -| country | S | Yes | tring? | -| zipCode | S | Yes | tring? | -| latitude | F | Yes | loat? | -| longitude | F | Yes | loat? | -| phone | S | Yes | tring? | -| email | S | Yes | tring? | -| website | S | Yes | tring? | -| image | S | Yes | tring? | -| coverImage | S | Yes | tring? | -| verified | B | Yes | oolean @default(false) | -| ownerId | S | Yes | tring | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| artists | A | Yes | rtist[] | -| shopArtists | S | Yes | hopArtist[] | -| bookings | B | Yes | ooking[] | -| reviews | R | Yes | eview[] | +| Field | Type | Required | Attributes | +| ----------- | ---- | -------- | -------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| name | S | Yes | tring | +| slug | S | Yes | tring @unique | +| description | S | Yes | tring? @db.Text | +| address | S | Yes | tring? | +| city | S | Yes | tring? | +| state | S | Yes | tring? | +| country | S | Yes | tring? | +| zipCode | S | Yes | tring? | +| latitude | F | Yes | loat? | +| longitude | F | Yes | loat? | +| phone | S | Yes | tring? | +| email | S | Yes | tring? | +| website | S | Yes | tring? | +| image | S | Yes | tring? | +| coverImage | S | Yes | tring? | +| verified | B | Yes | oolean @default(false) | +| ownerId | S | Yes | tring | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| artists | A | Yes | rtist[] | +| shopArtists | S | Yes | hopArtist[] | +| bookings | B | Yes | ooking[] | +| reviews | R | Yes | eview[] | **Indexes:** @@ -219,15 +219,15 @@ ### ShopArtist -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| shopId | S | Yes | tring | -| artistId | S | Yes | tring | -| role | S | Yes | hopArtistRole @default(ARTIST) | -| createdAt | D | Yes | ateTime @default(now()) | -| shop | S | Yes | hop @relation(fields: [shopId], references: [id], onDelete: Cascade) | -| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| shopId | S | Yes | tring | +| artistId | S | Yes | tring | +| role | S | Yes | hopArtistRole @default(ARTIST) | +| createdAt | D | Yes | ateTime @default(now()) | +| shop | S | Yes | hop @relation(fields: [shopId], references: [id], onDelete: Cascade) | +| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | **Indexes:** @@ -242,29 +242,29 @@ ### Tattoo -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| title | S | Yes | tring | -| slug | S | Yes | tring @unique | -| description | S | Yes | tring? @db.Text | -| imageUrl | S | Yes | tring | -| thumbnailUrl | S | Yes | tring? | -| blurDataUrl | S | Yes | tring? | -| styles | T | Yes | attooStyle[] | -| bodyPlacement | S | Yes | tring? | -| colorType | C | Yes | olorType @default(COLOR) | -| width | F | Yes | loat? | -| height | F | Yes | loat? | -| likesCount | I | Yes | nt @default(0) | -| viewsCount | I | Yes | nt @default(0) | -| artistId | S | Yes | tring | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | -| posts | P | Yes | ost[] | -| likes | L | Yes | ike[] | -| savedBy | S | Yes | avedTattoo[] | +| Field | Type | Required | Attributes | +| ------------- | ---- | -------- | ------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| title | S | Yes | tring | +| slug | S | Yes | tring @unique | +| description | S | Yes | tring? @db.Text | +| imageUrl | S | Yes | tring | +| thumbnailUrl | S | Yes | tring? | +| blurDataUrl | S | Yes | tring? | +| styles | T | Yes | attooStyle[] | +| bodyPlacement | S | Yes | tring? | +| colorType | C | Yes | olorType @default(COLOR) | +| width | F | Yes | loat? | +| height | F | Yes | loat? | +| likesCount | I | Yes | nt @default(0) | +| viewsCount | I | Yes | nt @default(0) | +| artistId | S | Yes | tring | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | +| posts | P | Yes | ost[] | +| likes | L | Yes | ike[] | +| savedBy | S | Yes | avedTattoo[] | **Indexes:** @@ -278,14 +278,14 @@ ### SavedTattoo -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| tattooId | S | Yes | tring | -| createdAt | D | Yes | ateTime @default(now()) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | -| tattoo | T | Yes | attoo @relation(fields: [tattooId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| tattooId | S | Yes | tring | +| createdAt | D | Yes | ateTime @default(now()) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| tattoo | T | Yes | attoo @relation(fields: [tattooId], references: [id], onDelete: Cascade) | **Indexes:** @@ -300,22 +300,22 @@ ### Post -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| caption | S | Yes | tring? @db.Text | -| imageUrl | S | Yes | tring? | -| tags | S | Yes | tring[] | -| authorId | S | Yes | tring | -| tattooId | S | Yes | tring? | -| likesCount | I | Yes | nt @default(0) | -| commentsCount | I | Yes | nt @default(0) | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | -| tattoo | T | Yes | attoo? @relation(fields: [tattooId], references: [id]) | -| comments | C | Yes | omment[] | -| likes | L | Yes | ike[] | +| Field | Type | Required | Attributes | +| ------------- | ---- | -------- | ---------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| caption | S | Yes | tring? @db.Text | +| imageUrl | S | Yes | tring? | +| tags | S | Yes | tring[] | +| authorId | S | Yes | tring | +| tattooId | S | Yes | tring? | +| likesCount | I | Yes | nt @default(0) | +| commentsCount | I | Yes | nt @default(0) | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | +| tattoo | T | Yes | attoo? @relation(fields: [tattooId], references: [id]) | +| comments | C | Yes | omment[] | +| likes | L | Yes | ike[] | **Indexes:** @@ -331,19 +331,19 @@ ### Comment -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| content | S | Yes | tring @db.Text | -| authorId | S | Yes | tring | -| postId | S | Yes | tring | -| parentId | S | Yes | tring? | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | -| post | P | Yes | ost @relation(fields: [postId], references: [id], onDelete: Cascade) | -| parent | C | Yes | omment? @relation("CommentReplies", fields: [parentId], references: [id]) | -| replies | C | Yes | omment[] @relation("CommentReplies") | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ------------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| content | S | Yes | tring @db.Text | +| authorId | S | Yes | tring | +| postId | S | Yes | tring | +| parentId | S | Yes | tring? | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | +| post | P | Yes | ost @relation(fields: [postId], references: [id], onDelete: Cascade) | +| parent | C | Yes | omment? @relation("CommentReplies", fields: [parentId], references: [id]) | +| replies | C | Yes | omment[] @relation("CommentReplies") | **Indexes:** @@ -360,19 +360,19 @@ ### Review -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| rating | I | Yes | nt | -| content | S | Yes | tring? @db.Text | -| authorId | S | Yes | tring | -| artistId | S | Yes | tring? | -| shopId | S | Yes | tring? | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | -| artist | A | Yes | rtist? @relation(fields: [artistId], references: [id]) | -| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ---------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| rating | I | Yes | nt | +| content | S | Yes | tring? @db.Text | +| authorId | S | Yes | tring | +| artistId | S | Yes | tring? | +| shopId | S | Yes | tring? | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| author | U | Yes | ser @relation(fields: [authorId], references: [id], onDelete: Cascade) | +| artist | A | Yes | rtist? @relation(fields: [artistId], references: [id]) | +| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | **Indexes:** @@ -388,31 +388,31 @@ ### Booking -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| artistId | S | Yes | tring | -| shopId | S | Yes | tring? | -| date | D | Yes | ateTime | -| duration | I | Yes | nt? | -| description | S | Yes | tring? @db.Text | -| tattooIdea | S | Yes | tring? @db.Text | -| placement | S | Yes | tring? | -| size | S | Yes | tring? | -| referenceImages | S | Yes | tring[] | -| reference | S | Yes | tring? | -| status | B | Yes | ookingStatus @default(PENDING) | -| price | D | Yes | ecimal? @db.Decimal(10, 2) | -| currency | S | Yes | tring @default("USD") | -| artistNotes | S | Yes | tring? @db.Text | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | -| artist | A | Yes | rtist @relation(fields: [artistId], references: [id]) | -| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | -| notifications | N | Yes | otification[] | -| conversation | C | Yes | onversation? | +| Field | Type | Required | Attributes | +| --------------- | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| artistId | S | Yes | tring | +| shopId | S | Yes | tring? | +| date | D | Yes | ateTime | +| duration | I | Yes | nt? | +| description | S | Yes | tring? @db.Text | +| tattooIdea | S | Yes | tring? @db.Text | +| placement | S | Yes | tring? | +| size | S | Yes | tring? | +| referenceImages | S | Yes | tring[] | +| reference | S | Yes | tring? | +| status | B | Yes | ookingStatus @default(PENDING) | +| price | D | Yes | ecimal? @db.Decimal(10, 2) | +| currency | S | Yes | tring @default("USD") | +| artistNotes | S | Yes | tring? @db.Text | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| artist | A | Yes | rtist @relation(fields: [artistId], references: [id]) | +| shop | S | Yes | hop? @relation(fields: [shopId], references: [id]) | +| notifications | N | Yes | otification[] | +| conversation | C | Yes | onversation? | **Indexes:** @@ -430,16 +430,16 @@ ### ArtistAvailability -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| artistId | S | Yes | tring | -| dayOfWeek | I | Yes | nt | -| startTime | S | Yes | tring | -| endTime | S | Yes | tring | -| createdAt | D | Yes | ateTime @default(now()) | -| updatedAt | D | Yes | ateTime @updatedAt | -| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| artistId | S | Yes | tring | +| dayOfWeek | I | Yes | nt | +| startTime | S | Yes | tring | +| endTime | S | Yes | tring | +| createdAt | D | Yes | ateTime @default(now()) | +| updatedAt | D | Yes | ateTime @updatedAt | +| artist | A | Yes | rtist @relation(fields: [artistId], references: [id], onDelete: Cascade) | **Indexes:** @@ -452,19 +452,19 @@ ### Notification -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| type | N | Yes | otificationType | -| title | S | Yes | tring | -| message | S | Yes | tring @db.Text | -| read | B | Yes | oolean @default(false) | -| bookingId | S | Yes | tring? | -| link | S | Yes | tring? | -| createdAt | D | Yes | ateTime @default(now()) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | -| booking | B | Yes | ooking? @relation(fields: [bookingId], references: [id]) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| type | N | Yes | otificationType | +| title | S | Yes | tring | +| message | S | Yes | tring @db.Text | +| read | B | Yes | oolean @default(false) | +| bookingId | S | Yes | tring? | +| link | S | Yes | tring? | +| createdAt | D | Yes | ateTime @default(now()) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| booking | B | Yes | ooking? @relation(fields: [bookingId], references: [id]) | **Indexes:** @@ -479,14 +479,14 @@ ### Follower -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| followerId | S | Yes | tring | -| followingId | S | Yes | tring | -| createdAt | D | Yes | ateTime @default(now()) | -| follower | U | Yes | ser @relation("Followers", fields: [followerId], references: [id], onDelete: Cascade) | -| following | U | Yes | ser @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| ----------- | ---- | -------- | -------------------------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| followerId | S | Yes | tring | +| followingId | S | Yes | tring | +| createdAt | D | Yes | ateTime @default(now()) | +| follower | U | Yes | ser @relation("Followers", fields: [followerId], references: [id], onDelete: Cascade) | +| following | U | Yes | ser @relation("Following", fields: [followingId], references: [id], onDelete: Cascade) | **Indexes:** @@ -501,18 +501,18 @@ ### AIGeneration -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| prompt | S | Yes | tring @db.Text | -| imageUrl | S | Yes | tring? | -| style | S | Yes | tring? | -| placement | S | Yes | tring? | -| colorType | S | Yes | tring? | -| status | A | Yes | IGenerationStatus @default(PENDING) | -| createdAt | D | Yes | ateTime @default(now()) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| prompt | S | Yes | tring @db.Text | +| imageUrl | S | Yes | tring? | +| style | S | Yes | tring? | +| placement | S | Yes | tring? | +| colorType | S | Yes | tring? | +| status | A | Yes | IGenerationStatus @default(PENDING) | +| createdAt | D | Yes | ateTime @default(now()) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | **Indexes:** @@ -525,21 +525,21 @@ ### TattooPreview -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| bodyImageUrl | S | Yes | tring | -| tattooImageUrl | S | Yes | tring | -| previewImageUrl | S | Yes | tring? | -| placement | S | Yes | tring? | -| positionX | F | Yes | loat @default(50) | -| positionY | F | Yes | loat @default(50) | -| scale | F | Yes | loat @default(1) | -| rotation | F | Yes | loat @default(0) | -| opacity | F | Yes | loat @default(0.85) | -| createdAt | D | Yes | ateTime @default(now()) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------------- | ---- | -------- | -------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| bodyImageUrl | S | Yes | tring | +| tattooImageUrl | S | Yes | tring | +| previewImageUrl | S | Yes | tring? | +| placement | S | Yes | tring? | +| positionX | F | Yes | loat @default(50) | +| positionY | F | Yes | loat @default(50) | +| scale | F | Yes | loat @default(1) | +| rotation | F | Yes | loat @default(0) | +| opacity | F | Yes | loat @default(0.85) | +| createdAt | D | Yes | ateTime @default(now()) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | **Indexes:** @@ -552,16 +552,16 @@ ### Like -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| userId | S | Yes | tring | -| postId | S | Yes | tring? | -| tattooId | S | Yes | tring? | -| createdAt | D | Yes | ateTime @default(now()) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | -| post | P | Yes | ost? @relation(fields: [postId], references: [id], onDelete: Cascade) | -| tattoo | T | Yes | attoo? @relation(fields: [tattooId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| --------- | ---- | -------- | ------------------------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| userId | S | Yes | tring | +| postId | S | Yes | tring? | +| tattooId | S | Yes | tring? | +| createdAt | D | Yes | ateTime @default(now()) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| post | P | Yes | ost? @relation(fields: [postId], references: [id], onDelete: Cascade) | +| tattoo | T | Yes | attoo? @relation(fields: [tattooId], references: [id], onDelete: Cascade) | **Indexes:** @@ -579,16 +579,16 @@ ### Conversation -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| bookingId | S | Yes | tring? @unique | -| lastMessage | S | Yes | tring? @db.Text | -| lastActivity | D | Yes | ateTime @default(now()) | -| createdAt | D | Yes | ateTime @default(now()) | -| booking | B | Yes | ooking? @relation(fields: [bookingId], references: [id]) | -| participants | C | Yes | onversationParticipant[] | -| messages | M | Yes | essage[] | +| Field | Type | Required | Attributes | +| ------------ | ---- | -------- | -------------------------------------------------------- | +| id | S | Yes | tring @id @default(cuid()) | +| bookingId | S | Yes | tring? @unique | +| lastMessage | S | Yes | tring? @db.Text | +| lastActivity | D | Yes | ateTime @default(now()) | +| createdAt | D | Yes | ateTime @default(now()) | +| booking | B | Yes | ooking? @relation(fields: [bookingId], references: [id]) | +| participants | C | Yes | onversationParticipant[] | +| messages | M | Yes | essage[] | **Indexes:** @@ -600,15 +600,15 @@ ### ConversationParticipant -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| conversationId | S | Yes | tring | -| userId | S | Yes | tring | -| lastReadAt | D | Yes | ateTime @default(now()) | -| createdAt | D | Yes | ateTime @default(now()) | -| conversation | C | Yes | onversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) | -| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| -------------- | ---- | -------- | ------------------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| conversationId | S | Yes | tring | +| userId | S | Yes | tring | +| lastReadAt | D | Yes | ateTime @default(now()) | +| createdAt | D | Yes | ateTime @default(now()) | +| conversation | C | Yes | onversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) | +| user | U | Yes | ser @relation(fields: [userId], references: [id], onDelete: Cascade) | **Indexes:** @@ -623,16 +623,16 @@ ### Message -| Field | Type | Required | Attributes | -|-------|------|----------|------------| -| id | S | Yes | tring @id @default(cuid()) | -| conversationId | S | Yes | tring | -| senderId | S | Yes | tring | -| content | S | Yes | tring @db.Text | -| read | B | Yes | oolean @default(false) | -| createdAt | D | Yes | ateTime @default(now()) | -| conversation | C | Yes | onversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) | -| sender | U | Yes | ser @relation(fields: [senderId], references: [id], onDelete: Cascade) | +| Field | Type | Required | Attributes | +| -------------- | ---- | -------- | ------------------------------------------------------------------------------------ | +| id | S | Yes | tring @id @default(cuid()) | +| conversationId | S | Yes | tring | +| senderId | S | Yes | tring | +| content | S | Yes | tring @db.Text | +| read | B | Yes | oolean @default(false) | +| createdAt | D | Yes | ateTime @default(now()) | +| conversation | C | Yes | onversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) | +| sender | U | Yes | ser @relation(fields: [senderId], references: [id], onDelete: Cascade) | **Indexes:** diff --git a/docs/migration-deploy.md b/docs/migration-deploy.md index a0758cc..85a1589 100644 --- a/docs/migration-deploy.md +++ b/docs/migration-deploy.md @@ -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): @@ -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 @@ -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 diff --git a/docs/pre-commit.md b/docs/pre-commit.md new file mode 100644 index 0000000..09864c3 --- /dev/null +++ b/docs/pre-commit.md @@ -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. diff --git a/docs/system-overview.md b/docs/system-overview.md index d25bac5..a912088 100644 --- a/docs/system-overview.md +++ b/docs/system-overview.md @@ -49,34 +49,37 @@ 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 ↓ @@ -84,6 +87,7 @@ Upload → MIME Validation → Size Check → Sharp Processing → S3/R2 Storage ``` ### Notification Flow + ``` User Action → API Route → Service (main logic) → Notification Service (async, non-blocking) ``` @@ -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) diff --git a/eslint.config.mjs b/eslint.config.mjs index 499e687..fc7639e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', }, }, ]); diff --git a/jest.config.ts b/jest.config.ts index f44f966..4f53923 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -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', ], @@ -28,7 +38,11 @@ const config: Config = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, - testMatch: ['/tests/unit/**/*.test.ts', '/tests/services/**/*.test.ts'], + testMatch: [ + '/tests/unit/**/*.test.ts', + '/tests/unit/**/*.test.tsx', + '/tests/services/**/*.test.ts', + ], testPathIgnorePatterns: ['/node_modules/', '/.next/', '/tests/e2e/'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: '/tsconfig.jest.json' }], diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..cdb2ea5 --- /dev/null +++ b/lighthouserc.json @@ -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" + } + } +} diff --git a/next.config.ts b/next.config.ts index 6b6593a..73493fa 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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' }, diff --git a/package.json b/package.json index 076759b..666ee67 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..297374d 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/prisma/fixtures/artists.ts b/prisma/fixtures/artists.ts new file mode 100644 index 0000000..f559d41 --- /dev/null +++ b/prisma/fixtures/artists.ts @@ -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]; diff --git a/prisma/fixtures/index.ts b/prisma/fixtures/index.ts new file mode 100644 index 0000000..040ea6d --- /dev/null +++ b/prisma/fixtures/index.ts @@ -0,0 +1,5 @@ +export { fixtureUsers } from './users'; +export { fixtureArtists } from './artists'; +export { fixtureShops } from './shops'; +export { fixtureTattoos } from './tattoos'; +export { fixturePosts } from './posts'; diff --git a/prisma/fixtures/posts.ts b/prisma/fixtures/posts.ts new file mode 100644 index 0000000..0c84a8f --- /dev/null +++ b/prisma/fixtures/posts.ts @@ -0,0 +1,24 @@ +export const fixturePosts = [ + { + id: 'fix_post_1', + caption: 'Fresh koi sleeve in progress.', + imageUrl: null, + tags: ['japanese', 'koi'], + authorId: 'fix_user_artist1', + tattooId: 'fix_tattoo_1', + likesCount: 8, + commentsCount: 1, + }, + { + id: 'fix_post_2', + caption: 'Tiny sun for a first tattoo.', + imageUrl: null, + tags: ['minimalist'], + authorId: 'fix_user_artist2', + tattooId: 'fix_tattoo_2', + likesCount: 4, + commentsCount: 0, + }, +]; + +export type FixturePost = (typeof fixturePosts)[number]; diff --git a/prisma/fixtures/shops.ts b/prisma/fixtures/shops.ts new file mode 100644 index 0000000..520dc6f --- /dev/null +++ b/prisma/fixtures/shops.ts @@ -0,0 +1,16 @@ +export const fixtureShops = [ + { + id: 'fix_shop_1', + name: 'Parlour Brooklyn', + slug: 'parlour-brooklyn', + description: 'Boutique studio in Williamsburg.', + address: '123 Bedford Ave', + city: 'Brooklyn', + state: 'NY', + country: 'USA', + ownerId: 'fix_user_shopowner', + verified: true, + }, +]; + +export type FixtureShop = (typeof fixtureShops)[number]; diff --git a/prisma/fixtures/tattoos.ts b/prisma/fixtures/tattoos.ts new file mode 100644 index 0000000..c2052e6 --- /dev/null +++ b/prisma/fixtures/tattoos.ts @@ -0,0 +1,36 @@ +export const fixtureTattoos = [ + { + id: 'fix_tattoo_1', + title: 'Koi Sleeve Sketch', + slug: 'koi-sleeve-sketch', + description: 'Half-sleeve koi flowing through waves.', + imageUrl: 'https://placehold.co/800x800/png?text=koi', + thumbnailUrl: 'https://placehold.co/240x240/png?text=koi', + blurDataUrl: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIHZpZXdCb3g9IjAgMCA4IDgiPjxyZWN0IHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9IiNlMmQ5ZjMiLz48L3N2Zz4=', + styles: ['JAPANESE'], + bodyPlacement: 'half-sleeve', + colorType: 'COLOR', + likesCount: 12, + viewsCount: 200, + artistId: 'fix_artist_1', + }, + { + id: 'fix_tattoo_2', + title: 'Minimal Sun', + slug: 'minimal-sun', + description: 'Single-line sun with rays.', + imageUrl: 'https://placehold.co/800x800/png?text=sun', + thumbnailUrl: 'https://placehold.co/240x240/png?text=sun', + blurDataUrl: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjgiIHZpZXdCb3g9IjAgMCA4IDgiPjxyZWN0IHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9IiNmZmYiLz48L3N2Zz4=', + styles: ['MINIMALIST', 'FINE_LINE'], + bodyPlacement: 'wrist', + colorType: 'BLACK_AND_GREY', + likesCount: 5, + viewsCount: 88, + artistId: 'fix_artist_2', + }, +]; + +export type FixtureTattoo = (typeof fixtureTattoos)[number]; diff --git a/prisma/fixtures/users.ts b/prisma/fixtures/users.ts new file mode 100644 index 0000000..ec68b4f --- /dev/null +++ b/prisma/fixtures/users.ts @@ -0,0 +1,51 @@ +// deterministic seed users — ids and emails stable across runs + +export const fixtureUsers = [ + { + id: 'fix_user_admin', + email: 'admin@aetch.test', + username: 'admin', + name: 'Admin User', + roles: ['ADMIN'] as const, + onboardingComplete: true, + favoriteStyles: [] as string[], + }, + { + id: 'fix_user_artist1', + email: 'artist1@aetch.test', + username: 'inkwell', + name: 'Maya Inkwell', + roles: ['USER', 'ARTIST'] as const, + onboardingComplete: true, + favoriteStyles: ['JAPANESE', 'BLACKWORK'], + }, + { + id: 'fix_user_artist2', + email: 'artist2@aetch.test', + username: 'finelineco', + name: 'Sam Fineline', + roles: ['USER', 'ARTIST'] as const, + onboardingComplete: true, + favoriteStyles: ['FINE_LINE', 'MINIMALIST'], + }, + { + id: 'fix_user_shopowner', + email: 'shop@aetch.test', + username: 'parlourmaster', + name: 'Parlour Owner', + roles: ['USER', 'SHOP_OWNER'] as const, + onboardingComplete: true, + favoriteStyles: [] as string[], + }, + { + id: 'fix_user_client', + email: 'client@aetch.test', + username: 'newink', + name: 'Casey Client', + roles: ['USER'] as const, + onboardingComplete: true, + favoriteStyles: ['NEO_TRADITIONAL'], + }, +]; + +export type FixtureUser = (typeof fixtureUsers)[number]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5464096..6a7114c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,10 @@ model User { roles UserRole[] @default([USER]) onboardingComplete Boolean @default(false) favoriteStyles String[] + // totp 2fa fields + totpSecret String? + totpEnabledAt DateTime? + totpRecoveryCodes String[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -80,6 +84,9 @@ model User { conversations ConversationParticipant[] sentMessages Message[] reports Report[] + auditLogs AdminAuditLog[] + uploadJobs UploadJob[] + collections Collection[] @@index([email]) @@index([username]) @@ -259,8 +266,11 @@ model Post { tags String[] authorId String tattooId String? + // repost / quote-tweet semantics + repostOfId String? likesCount Int @default(0) commentsCount Int @default(0) + repostsCount Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -268,11 +278,15 @@ model Post { tattoo Tattoo? @relation(fields: [tattooId], references: [id]) comments Comment[] likes Like[] + repostOf Post? @relation("Reposts", fields: [repostOfId], references: [id]) + reposts Post[] @relation("Reposts") @@index([authorId]) @@index([tattooId]) @@index([createdAt]) @@index([likesCount]) + @@index([repostOfId]) + @@index([tags]) } model Comment { @@ -328,6 +342,9 @@ model Booking { reference String? status BookingStatus @default(PENDING) price Decimal? @db.Decimal(10, 2) + // estimator output for training data + estimatedPrice Decimal? @db.Decimal(10, 2) + finalPrice Decimal? @db.Decimal(10, 2) currency String @default("USD") artistNotes String? @db.Text createdAt DateTime @default(now()) @@ -561,3 +578,102 @@ enum ReportStatus { RESOLVED DISMISSED } + +// admin audit log +model AdminAuditLog { + id String @id @default(cuid()) + actorId String + action AdminAuditAction + targetType String? + targetId String? + metadata Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + actor User @relation(fields: [actorId], references: [id], onDelete: Cascade) + + @@index([actorId]) + @@index([action]) + @@index([targetType, targetId]) + @@index([createdAt]) +} + +enum AdminAuditAction { + USER_DISABLE + USER_ENABLE + USER_ROLE_CHANGE + POST_REMOVE + TATTOO_REMOVE + COMMENT_REMOVE + REPORT_RESOLVE + REPORT_DISMISS + ARTIST_VERIFY + SHOP_VERIFY + TOTP_ENROLL + TOTP_DISABLE +} + +// upload audit + av scan tracking +model UploadJob { + id String @id @default(cuid()) + userId String + bucket String + key String + contentType String + sizeBytes Int? + scanStatus AvScanStatus @default(PENDING) + scanResult String? + scannedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([bucket, key]) + @@index([userId]) + @@index([scanStatus]) + @@index([createdAt]) +} + +enum AvScanStatus { + PENDING + CLEAN + INFECTED + ERROR + SKIPPED +} + +// pinterest-style boards +model Collection { + id String @id @default(cuid()) + name String + slug String + description String? @db.Text + coverImage String? + isPublic Boolean @default(true) + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + items CollectionItem[] + + @@unique([ownerId, slug]) + @@index([ownerId]) + @@index([isPublic]) +} + +model CollectionItem { + id String @id @default(cuid()) + collectionId String + tattooId String? + postId String? + note String? @db.Text + createdAt DateTime @default(now()) + + collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + + @@index([collectionId]) + @@index([tattooId]) + @@index([postId]) +} diff --git a/prisma/seed-fixtures.ts b/prisma/seed-fixtures.ts new file mode 100644 index 0000000..76384cb --- /dev/null +++ b/prisma/seed-fixtures.ts @@ -0,0 +1,102 @@ +import { PrismaClient, type Prisma } from '@prisma/client'; +import { + fixtureArtists, + fixturePosts, + fixtureShops, + fixtureTattoos, + fixtureUsers, +} from './fixtures'; + +// idempotent seed for tests + local dev +async function main() { + const prisma = new PrismaClient(); + + for (const u of fixtureUsers) { + await prisma.user.upsert({ + where: { id: u.id }, + update: {}, + create: { + id: u.id, + email: u.email, + username: u.username, + name: u.name, + roles: [...u.roles], + onboardingComplete: u.onboardingComplete, + favoriteStyles: [...u.favoriteStyles], + }, + }); + } + + for (const s of fixtureShops) { + await prisma.shop.upsert({ + where: { id: s.id }, + update: {}, + create: { ...s }, + }); + } + + for (const a of fixtureArtists) { + await prisma.artist.upsert({ + where: { id: a.id }, + update: {}, + create: { + id: a.id, + userId: a.userId, + displayName: a.displayName, + slug: a.slug, + bio: a.bio, + specialties: [...a.specialties], + hourlyRate: a.hourlyRate as unknown as Prisma.Decimal, + currency: a.currency, + location: a.location, + verified: a.verified, + }, + }); + } + + for (const t of fixtureTattoos) { + await prisma.tattoo.upsert({ + where: { id: t.id }, + update: {}, + create: { + id: t.id, + title: t.title, + slug: t.slug, + description: t.description, + imageUrl: t.imageUrl, + thumbnailUrl: t.thumbnailUrl, + blurDataUrl: t.blurDataUrl, + styles: [...t.styles] as unknown as Prisma.TattooCreatestylesInput, + bodyPlacement: t.bodyPlacement, + colorType: t.colorType as Prisma.TattooUncheckedCreateInput['colorType'], + likesCount: t.likesCount, + viewsCount: t.viewsCount, + artistId: t.artistId, + }, + }); + } + + for (const p of fixturePosts) { + await prisma.post.upsert({ + where: { id: p.id }, + update: {}, + create: { + id: p.id, + caption: p.caption, + imageUrl: p.imageUrl, + tags: [...p.tags], + authorId: p.authorId, + tattooId: p.tattooId, + likesCount: p.likesCount, + commentsCount: p.commentsCount, + }, + }); + } + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/generate-schema-doc.ts b/scripts/generate-schema-doc.ts index db238ac..4aeb553 100644 --- a/scripts/generate-schema-doc.ts +++ b/scripts/generate-schema-doc.ts @@ -49,8 +49,14 @@ function parseSchema(content: string) { // close block if (line === '}') { - if (current) { models.push(current); current = null; } - if (currentEnum) { enums.push(currentEnum); currentEnum = null; } + if (current) { + models.push(current); + current = null; + } + if (currentEnum) { + enums.push(currentEnum); + currentEnum = null; + } continue; } @@ -124,8 +130,8 @@ function generateMarkdown(models: Model[], enums: EnumDef[]) { } // relations - const relations = model.fields.filter( - (f) => f.attributes.some((a) => a.startsWith('@relation')), + const relations = model.fields.filter((f) => + f.attributes.some((a) => a.startsWith('@relation')), ); if (relations.length > 0) { lines.push('', '**Relations:**', ''); diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 5126f69..f6f7270 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -30,10 +30,13 @@ export default function ForgotPasswordPage() {

Check your email

- If an account exists for {email}, - we sent a sign-in link. + If an account exists for {email}, we sent a + sign-in link.

- + Back to sign in
@@ -43,9 +46,7 @@ export default function ForgotPasswordPage() { return (

Reset access

-

- Enter your email and we'll send a sign-in link -

+

Enter your email and we'll send a sign-in link

= { export default function LoginPage() { return ( - }> + + } + > ); @@ -62,12 +66,7 @@ function LoginForm() {

We sent a sign-in link to {email}

- setEmailSent(false)} - > + setEmailSent(false)}> Use a different email @@ -80,24 +79,28 @@ function LoginForm() {

Sign in to your AETCH account

{authError && ( - + )} {/* Google */} - + Continue with Google @@ -120,13 +123,7 @@ function LoginForm() { required autoComplete="email" /> - + Send sign-in link diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 9a11599..b5e3d1c 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -21,7 +21,11 @@ export default function RegisterPage() { setLoading(true); setError(''); try { - const result = await signIn('nodemailer', { email, callbackUrl: '/onboarding/role', redirect: false }); + const result = await signIn('nodemailer', { + email, + callbackUrl: '/onboarding/role', + redirect: false, + }); if (result?.error) { setError('Failed to send sign-in email. Please check the address.'); } else { @@ -46,12 +50,7 @@ export default function RegisterPage() {

We sent a link to {email} to get you started.

- setEmailSent(false)} - > + setEmailSent(false)}> Use a different email
@@ -65,17 +64,24 @@ export default function RegisterPage() { {error && } - + Sign up with Google @@ -96,13 +102,7 @@ export default function RegisterPage() { required autoComplete="email" /> - + Continue with email diff --git a/src/app/(onboarding)/onboarding/artist/page.tsx b/src/app/(onboarding)/onboarding/artist/page.tsx index 3b07bd0..ace1d76 100644 --- a/src/app/(onboarding)/onboarding/artist/page.tsx +++ b/src/app/(onboarding)/onboarding/artist/page.tsx @@ -11,9 +11,20 @@ import { slugify } from '@/utils/slugify'; import { cn } from '@/utils/cn'; const specialtyOptions = [ - 'Traditional', 'Neo-traditional', 'Japanese', 'Blackwork', 'Fine Line', - 'Minimalist', 'Realism', 'Tribal', 'Watercolor', 'Geometric', - 'Abstract', 'Dotwork', 'Biomechanical', 'Chicano', + 'Traditional', + 'Neo-traditional', + 'Japanese', + 'Blackwork', + 'Fine Line', + 'Minimalist', + 'Realism', + 'Tribal', + 'Watercolor', + 'Geometric', + 'Abstract', + 'Dotwork', + 'Biomechanical', + 'Chicano', ]; export default function ArtistOnboardingPage() { @@ -34,9 +45,7 @@ export default function ArtistOnboardingPage() { }; const toggleSpecialty = (s: string) => { - setSpecialties((prev) => - prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s], - ); + setSpecialties((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s])); }; const handleSubmit = async (e: React.FormEvent) => { diff --git a/src/app/(onboarding)/onboarding/role/page.tsx b/src/app/(onboarding)/onboarding/role/page.tsx index 2d8b96a..077c6ee 100644 --- a/src/app/(onboarding)/onboarding/role/page.tsx +++ b/src/app/(onboarding)/onboarding/role/page.tsx @@ -58,7 +58,9 @@ export default function RoleSelectionPage() { )} >
- +

{role.title}

{role.description}

diff --git a/src/app/(onboarding)/onboarding/user/page.tsx b/src/app/(onboarding)/onboarding/user/page.tsx index 19402cc..0f5b0a2 100644 --- a/src/app/(onboarding)/onboarding/user/page.tsx +++ b/src/app/(onboarding)/onboarding/user/page.tsx @@ -10,9 +10,18 @@ import { FormError } from '@/components/forms/form-error'; import { cn } from '@/utils/cn'; const tattooStyles = [ - 'Traditional', 'Neo-traditional', 'Japanese', 'Blackwork', 'Fine Line', - 'Minimalist', 'Realism', 'Tribal', 'Watercolor', 'Geometric', - 'Abstract', 'Dotwork', + 'Traditional', + 'Neo-traditional', + 'Japanese', + 'Blackwork', + 'Fine Line', + 'Minimalist', + 'Realism', + 'Tribal', + 'Watercolor', + 'Geometric', + 'Abstract', + 'Dotwork', ]; export default function UserOnboardingPage() { diff --git a/src/app/api/_error/route.ts b/src/app/api/_error/route.ts new file mode 100644 index 0000000..57642a5 --- /dev/null +++ b/src/app/api/_error/route.ts @@ -0,0 +1,40 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { captureException } from '@/lib/sentry'; +import { rateLimit } from '@/backend/middleware/rate-limit'; + +interface ErrorReport { + message?: string; + digest?: string; + stack?: string; + url?: string; +} + +// client error boundary sink +export async function POST(req: Request) { + const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'anon'; + const rl = await rateLimit(`err:${ip}`, 'api'); + if (!rl.success) return rl.error; + + let body: ErrorReport = {}; + try { + body = (await req.json()) as ErrorReport; + } catch { + return NextResponse.json({ ok: true }); + } + + const trimmed: ErrorReport = { + message: body.message?.slice(0, 500), + digest: body.digest?.slice(0, 64), + stack: body.stack?.slice(0, 4000), + url: body.url?.slice(0, 500), + }; + + captureException(new Error(trimmed.message ?? 'client error'), { + source: 'client-error-boundary', + ...trimmed, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/audit/route.ts b/src/app/api/admin/audit/route.ts new file mode 100644 index 0000000..2194440 --- /dev/null +++ b/src/app/api/admin/audit/route.ts @@ -0,0 +1,59 @@ +export const runtime = 'nodejs'; + +import { z } from 'zod'; +import { NextResponse } from 'next/server'; +import { requireRole } from '@/backend/middleware/role-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { listAuditEvents } from '@/backend/services/audit-log-service'; +import { paginationSchema } from '@/lib/validations'; +import { getPaginationParams } from '@/utils/pagination'; + +const ACTIONS = [ + 'USER_DISABLE', + 'USER_ENABLE', + 'USER_ROLE_CHANGE', + 'POST_REMOVE', + 'TATTOO_REMOVE', + 'COMMENT_REMOVE', + 'REPORT_RESOLVE', + 'REPORT_DISMISS', + 'ARTIST_VERIFY', + 'SHOP_VERIFY', + 'TOTP_ENROLL', + 'TOTP_DISABLE', +] as const; + +const filterSchema = z.object({ + actorId: z.string().cuid().optional(), + targetId: z.string().optional(), + action: z.enum(ACTIONS).optional(), +}); + +export const GET = withErrorHandler(async (req: Request) => { + const { error } = await requireRole('ADMIN'); + if (error) return withRequestId(req, error); + + const { searchParams } = new URL(req.url); + const page = paginationSchema.safeParse({ + page: searchParams.get('page'), + limit: searchParams.get('limit'), + }); + const pagination = getPaginationParams( + page.success ? page.data.page : 1, + page.success ? page.data.limit : 50, + ); + + const filters = filterSchema.safeParse({ + actorId: searchParams.get('actorId') ?? undefined, + targetId: searchParams.get('targetId') ?? undefined, + action: searchParams.get('action') ?? undefined, + }); + + const { events, pagination: meta } = await listAuditEvents( + filters.success ? filters.data : {}, + pagination, + ); + + return withRequestId(req, NextResponse.json({ success: true, events, pagination: meta })); +}, 'GET /api/admin/audit'); diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts index 944d1f9..5a2d524 100644 --- a/src/app/api/admin/posts/route.ts +++ b/src/app/api/admin/posts/route.ts @@ -2,23 +2,33 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { prisma } from '@/lib/prisma'; +import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; // delete post (admin only) -export async function DELETE(req: Request) { - const { error } = await requireRole('ADMIN'); - if (error) return error; +export const DELETE = withErrorHandler(async (req: Request) => { + const { session, error } = await requireRole('ADMIN'); + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); const postId = searchParams.get('id'); if (!postId) { - return NextResponse.json( - { success: false, error: 'Post ID required' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Post ID required' }, { status: 400 }), ); } await prisma.post.delete({ where: { id: postId } }); - return NextResponse.json({ success: true }); -} + await recordAuditEvent({ + actorId: session.user.id, + action: 'POST_REMOVE', + targetType: 'post', + targetId: postId, + ...clientContext(req), + }); + return withRequestId(req, NextResponse.json({ success: true })); +}, 'DELETE /api/admin/posts'); diff --git a/src/app/api/admin/tattoos/route.ts b/src/app/api/admin/tattoos/route.ts index 18cf3ad..c4f9a60 100644 --- a/src/app/api/admin/tattoos/route.ts +++ b/src/app/api/admin/tattoos/route.ts @@ -2,23 +2,33 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { deleteTattoo } from '@/backend/services/tattoo-service'; +import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; // delete tattoo (admin only) -export async function DELETE(req: Request) { - const { error } = await requireRole('ADMIN'); - if (error) return error; +export const DELETE = withErrorHandler(async (req: Request) => { + const { session, error } = await requireRole('ADMIN'); + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); const tattooId = searchParams.get('id'); if (!tattooId) { - return NextResponse.json( - { success: false, error: 'Tattoo ID required' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Tattoo ID required' }, { status: 400 }), ); } await deleteTattoo(tattooId); - return NextResponse.json({ success: true }); -} + await recordAuditEvent({ + actorId: session.user.id, + action: 'TATTOO_REMOVE', + targetType: 'tattoo', + targetId: tattooId, + ...clientContext(req), + }); + return withRequestId(req, NextResponse.json({ success: true })); +}, 'DELETE /api/admin/tattoos'); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 9cf2bcd..7b4f5e7 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -2,14 +2,17 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { prisma } from '@/lib/prisma'; import { paginationSchema } from '@/lib/validations'; import { getPaginationParams, buildPaginationMeta } from '@/utils/pagination'; +import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; // list users (admin only) -export async function GET(req: Request) { +export const GET = withErrorHandler(async (req: Request) => { const { error } = await requireRole('ADMIN'); - if (error) return error; + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); const page = paginationSchema.safeParse({ @@ -32,6 +35,7 @@ export async function GET(req: Request) { roles: true, onboardingComplete: true, createdAt: true, + totpEnabledAt: true, }, orderBy: { createdAt: 'desc' }, skip: pagination.skip, @@ -40,25 +44,28 @@ export async function GET(req: Request) { prisma.user.count(), ]); - return NextResponse.json({ - success: true, - users, - pagination: buildPaginationMeta(total, pagination), - }); -} + return withRequestId( + req, + NextResponse.json({ + success: true, + users, + pagination: buildPaginationMeta(total, pagination), + }), + ); +}, 'GET /api/admin/users'); // disable user (admin only) -export async function PATCH(req: Request) { - const { error } = await requireRole('ADMIN'); - if (error) return error; +export const PATCH = withErrorHandler(async (req: Request) => { + const { session, error } = await requireRole('ADMIN'); + if (error) return withRequestId(req, error); const body = await req.json(); const { userId, disabled } = body; if (!userId || typeof disabled !== 'boolean') { - return NextResponse.json( - { success: false, error: 'userId and disabled required' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: 'userId and disabled required' }, { status: 400 }), ); } @@ -70,5 +77,14 @@ export async function PATCH(req: Request) { }); } - return NextResponse.json({ success: true }); -} + await recordAuditEvent({ + actorId: session.user.id, + action: disabled ? 'USER_DISABLE' : 'USER_ENABLE', + targetType: 'user', + targetId: userId, + metadata: { disabled }, + ...clientContext(req), + }); + + return withRequestId(req, NextResponse.json({ success: true })); +}, 'PATCH /api/admin/users'); diff --git a/src/app/api/aftercare/route.ts b/src/app/api/aftercare/route.ts new file mode 100644 index 0000000..1e62a26 --- /dev/null +++ b/src/app/api/aftercare/route.ts @@ -0,0 +1,38 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { aftercareSchema } from '@/lib/validations'; +import { askAftercare } from '@/backend/services/aftercare-service'; + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'ai'); + if (!rl.success) return withRequestId(req, rl.error); + + const body = await req.json(); + const parsed = aftercareSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + try { + const reply = await askAftercare(parsed.data); + return withRequestId(req, NextResponse.json({ success: true, reply })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Aftercare failed'; + const status = message.includes('disabled') ? 403 : 500; + return withRequestId(req, NextResponse.json({ success: false, error: message }, { status })); + } +}, 'POST /api/aftercare'); diff --git a/src/app/api/ai/generate/route.ts b/src/app/api/ai/generate/route.ts index b5cac26..894edf6 100644 --- a/src/app/api/ai/generate/route.ts +++ b/src/app/api/ai/generate/route.ts @@ -1,37 +1,41 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { generateTattooDesign } from '@/backend/services/ai-service'; import { aiGenerateSchema } from '@/lib/validations'; -export async function POST(req: Request) { +export const POST = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); - // rate limit ai generation - const rl = await rateLimit(session!.user.id, 'ai-generation'); - if (!rl.success) return rl.error; + const rl = await rateLimit(session.user.id, 'ai-generation'); + if (!rl.success) return withRequestId(req, rl.error); const body = await req.json(); const parsed = aiGenerateSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), ); } try { const generation = await generateTattooDesign({ - userId: session!.user.id, + userId: session.user.id, ...parsed.data, }); - return NextResponse.json({ generation }, { status: 201 }); + return withRequestId(req, NextResponse.json({ generation }, { status: 201 })); } catch (err) { const message = err instanceof Error ? err.message : 'Generation failed'; const status = message.includes('limit') || message.includes('disabled') ? 429 : 500; - return NextResponse.json({ error: message }, { status }); + return withRequestId(req, NextResponse.json({ error: message }, { status })); } -} +}, 'POST /api/ai/generate'); diff --git a/src/app/api/ai/generations/[id]/route.ts b/src/app/api/ai/generations/[id]/route.ts index a2f8c04..6c1b4a1 100644 --- a/src/app/api/ai/generations/[id]/route.ts +++ b/src/app/api/ai/generations/[id]/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; diff --git a/src/app/api/ai/history/route.ts b/src/app/api/ai/history/route.ts index fe35177..0257b6c 100644 --- a/src/app/api/ai/history/route.ts +++ b/src/app/api/ai/history/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; diff --git a/src/app/api/ar-preview/[id]/route.ts b/src/app/api/ar-preview/[id]/route.ts index 4302945..4326e56 100644 --- a/src/app/api/ar-preview/[id]/route.ts +++ b/src/app/api/ar-preview/[id]/route.ts @@ -1,13 +1,10 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; import { getPreviewById, deletePreview } from '@/backend/services/ar-preview-service'; -export async function GET( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { session, error } = await authGuard(); if (error) return error; @@ -21,10 +18,7 @@ export async function GET( return NextResponse.json({ preview }); } -export async function DELETE( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { session, error } = await authGuard(); if (error) return error; diff --git a/src/app/api/ar-preview/history/route.ts b/src/app/api/ar-preview/history/route.ts index a151b14..02c53dd 100644 --- a/src/app/api/ar-preview/history/route.ts +++ b/src/app/api/ar-preview/history/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; diff --git a/src/app/api/ar-preview/route.ts b/src/app/api/ar-preview/route.ts index d694cbc..074f694 100644 --- a/src/app/api/ar-preview/route.ts +++ b/src/app/api/ar-preview/route.ts @@ -1,37 +1,41 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { savePreview } from '@/backend/services/ar-preview-service'; import { savePreviewSchema } from '@/lib/validations'; -export async function POST(req: Request) { +export const POST = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); - // rate limit ar preview saves - const rl = await rateLimit(session!.user.id, 'ar-preview'); - if (!rl.success) return rl.error; + const rl = await rateLimit(session.user.id, 'ar-preview'); + if (!rl.success) return withRequestId(req, rl.error); const body = await req.json(); const parsed = savePreviewSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), ); } try { const preview = await savePreview({ - userId: session!.user.id, + userId: session.user.id, ...parsed.data, }); - return NextResponse.json({ preview }, { status: 201 }); + return withRequestId(req, NextResponse.json({ preview }, { status: 201 })); } catch (err) { const message = err instanceof Error ? err.message : 'Save failed'; const status = message.includes('disabled') ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return withRequestId(req, NextResponse.json({ error: message }, { status })); } -} +}, 'POST /api/ar-preview'); diff --git a/src/app/api/artists/analytics/route.ts b/src/app/api/artists/analytics/route.ts index 498df86..95583ed 100644 --- a/src/app/api/artists/analytics/route.ts +++ b/src/app/api/artists/analytics/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; @@ -43,9 +43,7 @@ export async function GET() { ]); // map booking counts by status - const bookingMap = Object.fromEntries( - bookings.map((b) => [b.status.toLowerCase(), b._count.id]), - ); + const bookingMap = Object.fromEntries(bookings.map((b) => [b.status.toLowerCase(), b._count.id])); return NextResponse.json({ success: true, diff --git a/src/app/api/artists/availability/route.ts b/src/app/api/artists/availability/route.ts index f5105d3..9106b8a 100644 --- a/src/app/api/artists/availability/route.ts +++ b/src/app/api/artists/availability/route.ts @@ -1,8 +1,12 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; -import { getArtistByUserId, getArtistAvailability, setArtistAvailability } from '@/backend/services/artist-service'; +import { + getArtistByUserId, + getArtistAvailability, + setArtistAvailability, +} from '@/backend/services/artist-service'; import { availabilityBulkSchema } from '@/lib/validations'; export async function GET(req: Request) { diff --git a/src/app/api/artists/me/route.ts b/src/app/api/artists/me/route.ts index 4880d9f..e4cda0c 100644 --- a/src/app/api/artists/me/route.ts +++ b/src/app/api/artists/me/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index cfa54f1..4439d29 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { handlers } from '@/lib/auth'; diff --git a/src/app/api/auth/totp/disable/route.ts b/src/app/api/auth/totp/disable/route.ts new file mode 100644 index 0000000..3fe2b22 --- /dev/null +++ b/src/app/api/auth/totp/disable/route.ts @@ -0,0 +1,65 @@ +export const runtime = 'nodejs'; + +import { z } from 'zod'; +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { prisma } from '@/lib/prisma'; +import { consumeRecoveryCode, verifyToken } from '@/lib/totp'; +import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; + +const bodySchema = z.object({ + token: z.string().min(6).max(20), +}); + +// disable 2fa - accepts current totp or a recovery code +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid input' }, { status: 400 }), + ); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { totpSecret: true, totpRecoveryCodes: true, totpEnabledAt: true }, + }); + if (!user?.totpEnabledAt || !user.totpSecret) { + return withRequestId( + req, + NextResponse.json({ success: false, error: '2fa not enabled' }, { status: 400 }), + ); + } + + const submitted = parsed.data.token; + const isTotp = /^\d{6}$/.test(submitted) && verifyToken(user.totpSecret, submitted); + const remaining = isTotp ? null : consumeRecoveryCode(user.totpRecoveryCodes, submitted); + if (!isTotp && remaining === null) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid code' }, { status: 401 }), + ); + } + + await prisma.user.update({ + where: { id: session.user.id }, + data: { totpSecret: null, totpEnabledAt: null, totpRecoveryCodes: [] }, + }); + + await recordAuditEvent({ + actorId: session.user.id, + action: 'TOTP_DISABLE', + targetType: 'user', + targetId: session.user.id, + ...clientContext(req), + }); + + return withRequestId(req, NextResponse.json({ success: true })); +}, 'POST /api/auth/totp/disable'); diff --git a/src/app/api/auth/totp/setup/route.ts b/src/app/api/auth/totp/setup/route.ts new file mode 100644 index 0000000..0d1b579 --- /dev/null +++ b/src/app/api/auth/totp/setup/route.ts @@ -0,0 +1,41 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { prisma } from '@/lib/prisma'; +import { generateSecret, otpauthUri } from '@/lib/totp'; + +// step 1: generate secret + otpauth uri (client renders qr) +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const userRoles = session.user.roles ?? []; + if (!userRoles.includes('ADMIN') && !userRoles.includes('SHOP_OWNER')) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: '2fa is required only for ADMIN and SHOP_OWNER' }, + { status: 403 }, + ), + ); + } + + const secret = generateSecret(); + // stage but do not enable until verify succeeds + await prisma.user.update({ + where: { id: session.user.id }, + data: { totpSecret: secret, totpEnabledAt: null }, + }); + + const account = session.user.email ?? session.user.username ?? session.user.id; + const uri = otpauthUri({ + secret, + account, + issuer: process.env.TOTP_ISSUER ?? 'AETCH', + }); + + return withRequestId(req, NextResponse.json({ success: true, secret, otpauthUri: uri })); +}, 'POST /api/auth/totp/setup'); diff --git a/src/app/api/auth/totp/verify/route.ts b/src/app/api/auth/totp/verify/route.ts new file mode 100644 index 0000000..20a12cd --- /dev/null +++ b/src/app/api/auth/totp/verify/route.ts @@ -0,0 +1,61 @@ +export const runtime = 'nodejs'; + +import { z } from 'zod'; +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { prisma } from '@/lib/prisma'; +import { generateRecoveryCodes, verifyToken } from '@/lib/totp'; +import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; + +const bodySchema = z.object({ token: z.string().regex(/^\d{6}$/) }); + +// step 2: verify token + commit enrollment +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid token' }, { status: 400 }), + ); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { totpSecret: true }, + }); + if (!user?.totpSecret) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'totp setup not started' }, { status: 400 }), + ); + } + + if (!verifyToken(user.totpSecret, parsed.data.token)) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid code' }, { status: 401 }), + ); + } + + const recovery = generateRecoveryCodes(); + await prisma.user.update({ + where: { id: session.user.id }, + data: { totpEnabledAt: new Date(), totpRecoveryCodes: recovery }, + }); + + await recordAuditEvent({ + actorId: session.user.id, + action: 'TOTP_ENROLL', + targetType: 'user', + targetId: session.user.id, + ...clientContext(req), + }); + + return withRequestId(req, NextResponse.json({ success: true, recoveryCodes: recovery })); +}, 'POST /api/auth/totp/verify'); diff --git a/src/app/api/bookings/[id]/route.ts b/src/app/api/bookings/[id]/route.ts index 7ea202d..c772dda 100644 --- a/src/app/api/bookings/[id]/route.ts +++ b/src/app/api/bookings/[id]/route.ts @@ -1,9 +1,15 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; import { requireRole } from '@/backend/middleware/role-guard'; -import { getBookingById, updateBookingStatus, VALID_TRANSITIONS } from '@/backend/services/booking-service'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { + getBookingById, + updateBookingStatus, + VALID_TRANSITIONS, +} from '@/backend/services/booking-service'; import { getArtistByUserId } from '@/backend/services/artist-service'; import { notifyBookingStatusChange } from '@/backend/services/notification-service'; import { updateBookingStatusSchema } from '@/lib/validations'; @@ -12,57 +18,68 @@ interface Params { params: Promise<{ id: string }>; } -export async function GET(req: Request, { params }: Params) { +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { id } = await params; const booking = await getBookingById(id); if (!booking) { - return NextResponse.json({ error: 'Booking not found' }, { status: 404 }); + return withRequestId(req, NextResponse.json({ error: 'Booking not found' }, { status: 404 })); } // verify owner or artist const artist = await getArtistByUserId(session.user.id); if (booking.userId !== session.user.id && booking.artistId !== artist?.id) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return withRequestId(req, NextResponse.json({ error: 'Forbidden' }, { status: 403 })); } - return NextResponse.json(booking); -} + return withRequestId(req, NextResponse.json(booking)); +}, 'GET /api/bookings/[id]'); // artist updates booking status -export async function PATCH(req: Request, { params }: Params) { +export const PATCH = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await requireRole('ARTIST', 'ADMIN'); - if (error) return error; + if (error) return withRequestId(req, error); const { id } = await params; const body = await req.json(); const parsed = updateBookingStatusSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { success: false, error: parsed.error.issues[0].message }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: parsed.error.issues[0].message }, { status: 400 }), ); } - const artist = await getArtistByUserId(session!.user.id); + const artist = await getArtistByUserId(session.user.id); if (!artist) { - return NextResponse.json({ error: 'Artist profile not found' }, { status: 404 }); + return withRequestId( + req, + NextResponse.json({ error: 'Artist profile not found' }, { status: 404 }), + ); } // validate state transition const existing = await getBookingById(id); if (!existing) { - return NextResponse.json({ error: 'Booking not found' }, { status: 404 }); + return withRequestId(req, NextResponse.json({ error: 'Booking not found' }, { status: 404 })); } const allowed = VALID_TRANSITIONS[existing.status] ?? []; if (!allowed.includes(parsed.data.status)) { - return NextResponse.json( - { success: false, error: `Cannot transition from ${existing.status} to ${parsed.data.status}` }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { + success: false, + error: `Cannot transition from ${existing.status} to ${parsed.data.status}`, + }, + { status: 400 }, + ), ); } @@ -77,8 +94,11 @@ export async function PATCH(req: Request, { params }: Params) { artist.displayName, ).catch(() => {}); - return NextResponse.json({ success: true, booking }); + return withRequestId(req, NextResponse.json({ success: true, booking })); } catch { - return NextResponse.json({ error: 'Booking not found or unauthorized' }, { status: 404 }); + return withRequestId( + req, + NextResponse.json({ error: 'Booking not found or unauthorized' }, { status: 404 }), + ); } -} +}, 'PATCH /api/bookings/[id]'); diff --git a/src/app/api/bookings/artist/route.ts b/src/app/api/bookings/artist/route.ts index f120c87..336e8d1 100644 --- a/src/app/api/bookings/artist/route.ts +++ b/src/app/api/bookings/artist/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts index f0de710..6011a64 100644 --- a/src/app/api/bookings/route.ts +++ b/src/app/api/bookings/route.ts @@ -1,17 +1,23 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; -import { createBooking, getUserBookings, hasConflictingBooking } from '@/backend/services/booking-service'; +import { + createBooking, + getUserBookings, + hasConflictingBooking, +} from '@/backend/services/booking-service'; import { getArtistByUserId } from '@/backend/services/artist-service'; import { notifyBookingRequest } from '@/backend/services/notification-service'; import { bookingRequestSchema, paginationSchema } from '@/lib/validations'; import { getPaginationParams } from '@/utils/pagination'; -export async function GET(req: Request) { +export const GET = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); const parsed = paginationSchema.safeParse({ @@ -23,23 +29,26 @@ export async function GET(req: Request) { const pagination = getPaginationParams(page, limit); const result = await getUserBookings(session.user.id, pagination); - return NextResponse.json(result); -} + return withRequestId(req, NextResponse.json(result)); +}, 'GET /api/bookings'); -export async function POST(req: Request) { +export const POST = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const rl = await rateLimit(session.user.id, 'api'); - if (!rl.success) return rl.error; + if (!rl.success) return withRequestId(req, rl.error); try { const body = await req.json(); const parsed = bookingRequestSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { success: false, error: parsed.error.issues[0].message }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0].message }, + { status: 400 }, + ), ); } @@ -48,9 +57,12 @@ export async function POST(req: Request) { // reject past date bookings const bookingDate = new Date(date); if (bookingDate <= new Date()) { - return NextResponse.json( - { success: false, error: 'Booking date must be in the future' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { success: false, error: 'Booking date must be in the future' }, + { status: 400 }, + ), ); } @@ -60,9 +72,12 @@ export async function POST(req: Request) { // prevent double booking const conflict = await hasConflictingBooking(artistId, bookingDate); if (conflict) { - return NextResponse.json( - { success: false, error: 'Artist has a conflicting booking in that time window' }, - { status: 409 }, + return withRequestId( + req, + NextResponse.json( + { success: false, error: 'Artist has a conflicting booking in that time window' }, + { status: 409 }, + ), ); } @@ -79,16 +94,17 @@ export async function POST(req: Request) { // notify artist (non-blocking) if (artist) { - notifyBookingRequest( - booking.id, - artist.userId, - session.user.name ?? 'Someone', - ).catch(() => {}); + notifyBookingRequest(booking.id, artist.userId, session.user.name ?? 'Someone').catch( + () => {}, + ); } - return NextResponse.json({ success: true, booking }, { status: 201 }); + return withRequestId(req, NextResponse.json({ success: true, booking }, { status: 201 })); } catch (err) { const message = err instanceof Error ? err.message : 'Booking failed'; - return NextResponse.json({ success: false, error: message }, { status: 400 }); + return withRequestId( + req, + NextResponse.json({ success: false, error: message }, { status: 400 }), + ); } -} +}, 'POST /api/bookings'); diff --git a/src/app/api/collections/[id]/items/route.ts b/src/app/api/collections/[id]/items/route.ts new file mode 100644 index 0000000..7055fc8 --- /dev/null +++ b/src/app/api/collections/[id]/items/route.ts @@ -0,0 +1,71 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { addCollectionItem, removeCollectionItem } from '@/backend/services/collection-service'; +import { addToCollectionSchema } from '@/lib/validations'; + +interface Params { + params: Promise<{ id: string }>; +} + +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); + + const { id: collectionId } = await params; + const body = await req.json(); + const parsed = addToCollectionSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + try { + const item = await addCollectionItem({ + collectionId, + ownerId: session.user.id, + ...parsed.data, + }); + return withRequestId(req, NextResponse.json({ success: true, item }, { status: 201 })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed'; + return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 404 })); + } +}, 'POST /api/collections/[id]/items'); + +export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { + // /api/collections/[id]/items?itemId=... + const { params } = ctx as Params; + await params; // collectionId not actually needed — itemId scopes the row + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { searchParams } = new URL(req.url); + const itemId = searchParams.get('itemId'); + if (!itemId) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'itemId required' }, { status: 400 }), + ); + } + + try { + await removeCollectionItem(itemId, session.user.id); + return withRequestId(req, NextResponse.json({ success: true })); + } catch { + return withRequestId(req, NextResponse.json({ success: false, error: 'Item not found' }, { status: 404 })); + } +}, 'DELETE /api/collections/[id]/items'); diff --git a/src/app/api/collections/route.ts b/src/app/api/collections/route.ts new file mode 100644 index 0000000..bd7dc25 --- /dev/null +++ b/src/app/api/collections/route.ts @@ -0,0 +1,68 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { + createCollection, + getCollectionsForOwner, +} from '@/backend/services/collection-service'; +import { createCollectionSchema, paginationSchema } from '@/lib/validations'; +import { getPaginationParams } from '@/utils/pagination'; + +export const GET = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { searchParams } = new URL(req.url); + const page = paginationSchema.safeParse({ + page: searchParams.get('page'), + limit: searchParams.get('limit'), + }); + const pagination = getPaginationParams( + page.success ? page.data.page : 1, + page.success ? page.data.limit : 20, + ); + + const result = await getCollectionsForOwner(session.user.id, pagination); + return withRequestId(req, NextResponse.json({ success: true, ...result })); +}, 'GET /api/collections'); + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); + + const body = await req.json(); + const parsed = createCollectionSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + try { + const collection = await createCollection({ + ownerId: session.user.id, + ...parsed.data, + }); + return withRequestId(req, NextResponse.json({ success: true, collection }, { status: 201 })); + } catch { + // p2002 unique violation on (ownerId, slug) + return withRequestId( + req, + NextResponse.json( + { success: false, error: 'Collection with that slug already exists' }, + { status: 409 }, + ), + ); + } +}, 'POST /api/collections'); diff --git a/src/app/api/comments/[id]/replies/route.ts b/src/app/api/comments/[id]/replies/route.ts new file mode 100644 index 0000000..8e3f92e --- /dev/null +++ b/src/app/api/comments/[id]/replies/route.ts @@ -0,0 +1,18 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { getCommentReplies } from '@/backend/services/comment-service'; + +interface Params { + params: Promise<{ id: string }>; +} + +// fetch full reply list for a parent comment (depth-1 threading) +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; + const { id } = await params; + const replies = await getCommentReplies(id); + return withRequestId(req, NextResponse.json({ replies })); +}, 'GET /api/comments/[id]/replies'); diff --git a/src/app/api/coverup/route.ts b/src/app/api/coverup/route.ts new file mode 100644 index 0000000..17abdf0 --- /dev/null +++ b/src/app/api/coverup/route.ts @@ -0,0 +1,41 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { generateCoverupDesign } from '@/backend/services/ai-service'; +import { coverupSchema } from '@/lib/validations'; + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'ai-generation'); + if (!rl.success) return withRequestId(req, rl.error); + + const body = await req.json(); + const parsed = coverupSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + try { + const generation = await generateCoverupDesign({ + userId: session.user.id, + ...parsed.data, + }); + return withRequestId(req, NextResponse.json({ success: true, generation }, { status: 201 })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Generation failed'; + const status = message.includes('limit') || message.includes('disabled') ? 429 : 500; + return withRequestId(req, NextResponse.json({ success: false, error: message }, { status })); + } +}, 'POST /api/coverup'); diff --git a/src/app/api/messages/conversations/route.ts b/src/app/api/messages/conversations/route.ts index 483be66..8075888 100644 --- a/src/app/api/messages/conversations/route.ts +++ b/src/app/api/messages/conversations/route.ts @@ -20,10 +20,7 @@ export async function GET(req: Request) { const page = parsed.success ? parsed.data.page : 1; const limit = parsed.success ? parsed.data.limit : 20; - const result = await getUserConversations( - session.user.id, - getPaginationParams(page, limit), - ); + const result = await getUserConversations(session.user.id, getPaginationParams(page, limit)); return NextResponse.json(result); } @@ -38,10 +35,7 @@ export async function POST(req: Request) { const body = await req.json(); const parsed = createConversationSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message }, - { status: 400 }, - ); + return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 }); } try { diff --git a/src/app/api/messages/send/route.ts b/src/app/api/messages/send/route.ts index 5f4bb8b..3fe85f6 100644 --- a/src/app/api/messages/send/route.ts +++ b/src/app/api/messages/send/route.ts @@ -19,10 +19,7 @@ export async function POST(req: Request) { const body = await req.json(); const parsed = sendMessageSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message }, - { status: 400 }, - ); + return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 }); } try { @@ -33,20 +30,23 @@ export async function POST(req: Request) { ); // notify recipient (non-blocking) - prisma.conversationParticipant.findMany({ - where: { - conversationId: parsed.data.conversationId, - userId: { not: session.user.id }, - }, - }).then((participants) => { - for (const p of participants) { - notifyNewMessage( - p.userId, - session.user.name ?? 'Someone', - parsed.data.conversationId, - ).catch(() => {}); - } - }).catch(() => {}); + prisma.conversationParticipant + .findMany({ + where: { + conversationId: parsed.data.conversationId, + userId: { not: session.user.id }, + }, + }) + .then((participants) => { + for (const p of participants) { + notifyNewMessage( + p.userId, + session.user.name ?? 'Someone', + parsed.data.conversationId, + ).catch(() => {}); + } + }) + .catch(() => {}); return NextResponse.json({ message }, { status: 201 }); } catch (err) { diff --git a/src/app/api/notifications/read/route.ts b/src/app/api/notifications/read/route.ts index e54ef0b..17f0ad3 100644 --- a/src/app/api/notifications/read/route.ts +++ b/src/app/api/notifications/read/route.ts @@ -2,23 +2,25 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { markAsRead } from '@/backend/services/notification-service'; // mark single notification as read -export async function PATCH(req: Request) { +export const PATCH = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const body = await req.json(); const { notificationId } = body; if (!notificationId || typeof notificationId !== 'string') { - return NextResponse.json( - { success: false, error: 'notificationId required' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: 'notificationId required' }, { status: 400 }), ); } await markAsRead(notificationId, session.user.id); - return NextResponse.json({ success: true }); -} + return withRequestId(req, NextResponse.json({ success: true })); +}, 'PATCH /api/notifications/read'); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 6530c09..31d7a49 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,30 +1,36 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; -import { getUserNotifications, getUnreadCount, markAllAsRead } from '@/backend/services/notification-service'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { + getUserNotifications, + getUnreadCount, + markAllAsRead, +} from '@/backend/services/notification-service'; -export async function GET(req: Request) { +export const GET = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); const countOnly = searchParams.get('count') === 'true'; if (countOnly) { const count = await getUnreadCount(session.user.id); - return NextResponse.json({ count }); + return withRequestId(req, NextResponse.json({ count })); } const notifications = await getUserNotifications(session.user.id); - return NextResponse.json({ notifications }); -} + return withRequestId(req, NextResponse.json({ notifications })); +}, 'GET /api/notifications'); // mark all as read -export async function PATCH() { +export const PATCH = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); await markAllAsRead(session.user.id); - return NextResponse.json({ success: true }); -} + return withRequestId(req, NextResponse.json({ success: true })); +}, 'PATCH /api/notifications'); diff --git a/src/app/api/posts/[id]/comments/route.ts b/src/app/api/posts/[id]/comments/route.ts index 8219ac9..660c420 100644 --- a/src/app/api/posts/[id]/comments/route.ts +++ b/src/app/api/posts/[id]/comments/route.ts @@ -1,7 +1,9 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { createComment, getPostComments } from '@/backend/services/comment-service'; import { getPostById } from '@/backend/services/post-service'; import { notifyPostComment } from '@/backend/services/notification-service'; @@ -12,43 +14,50 @@ interface Params { params: Promise<{ id: string }>; } -export async function GET(_req: Request, { params }: Params) { +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { id } = await params; const comments = await getPostComments(id); - return NextResponse.json({ comments }); -} + return withRequestId(req, NextResponse.json({ comments })); +}, 'GET /api/posts/[id]/comments'); -export async function POST(req: Request, { params }: Params) { +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); - const rl = await rateLimit(session!.user.id, 'api'); - if (!rl.success) return rl.error; + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); const { id: postId } = await params; const body = await req.json(); const parsed = createCommentSchema.safeParse({ ...body, postId }); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), ); } const comment = await createComment({ - authorId: session!.user.id, + authorId: session.user.id, postId: parsed.data.postId, content: parsed.data.content, parentId: parsed.data.parentId, }); // notify author (non-blocking) - getPostById(postId).then((post) => { - if (post && post.authorId !== session!.user.id) { - notifyPostComment(post.authorId, postId, session!.user.name ?? 'Someone'); - } - }).catch(() => {}); - - return NextResponse.json({ comment }, { status: 201 }); -} + getPostById(postId) + .then((post) => { + if (post && post.authorId !== session.user.id) { + notifyPostComment(post.authorId, postId, session.user.name ?? 'Someone'); + } + }) + .catch(() => {}); + + return withRequestId(req, NextResponse.json({ comment }, { status: 201 })); +}, 'POST /api/posts/[id]/comments'); diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index c412b62..1901277 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -1,7 +1,9 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { togglePostLike, getPostById } from '@/backend/services/post-service'; import { notifyPostLike } from '@/backend/services/notification-service'; @@ -10,24 +12,27 @@ interface Params { params: Promise<{ id: string }>; } -export async function POST(_req: Request, { params }: Params) { +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const rl = await rateLimit(session.user.id, 'api'); - if (!rl.success) return rl.error; + if (!rl.success) return withRequestId(req, rl.error); const { id } = await params; - const result = await togglePostLike(session!.user.id, id); + const result = await togglePostLike(session.user.id, id); // notify author (non-blocking) if (result.liked) { - getPostById(id).then((post) => { - if (post && post.authorId !== session!.user.id) { - notifyPostLike(post.authorId, id, session!.user.name ?? 'Someone'); - } - }).catch(() => {}); + getPostById(id) + .then((post) => { + if (post && post.authorId !== session.user.id) { + notifyPostLike(post.authorId, id, session.user.name ?? 'Someone'); + } + }) + .catch(() => {}); } - return NextResponse.json({ success: true, ...result }); -} + return withRequestId(req, NextResponse.json({ success: true, ...result })); +}, 'POST /api/posts/[id]/like'); diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts new file mode 100644 index 0000000..52086b2 --- /dev/null +++ b/src/app/api/posts/[id]/repost/route.ts @@ -0,0 +1,54 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { deleteRepost, repostPost } from '@/backend/services/repost-service'; +import { repostSchema } from '@/lib/validations'; + +interface Params { + params: Promise<{ id: string }>; +} + +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); + + const { id } = await params; + let caption: string | undefined; + try { + const body = await req.json(); + const parsed = repostSchema.safeParse({ postId: id, caption: body?.caption }); + if (parsed.success) caption = parsed.data.caption; + } catch { + /* no body — silent repost */ + } + + try { + const repost = await repostPost(session.user.id, id, caption); + return withRequestId(req, NextResponse.json({ success: true, repost }, { status: 201 })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Repost failed'; + return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 400 })); + } +}, 'POST /api/posts/[id]/repost'); + +export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { id } = await params; + try { + await deleteRepost(session.user.id, id); + return withRequestId(req, NextResponse.json({ success: true })); + } catch { + return withRequestId(req, NextResponse.json({ success: false, error: 'Repost not found' }, { status: 404 })); + } +}, 'DELETE /api/posts/[id]/repost'); diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 2da6ab3..1426dca 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -1,31 +1,35 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { getPostById, deletePost } from '@/backend/services/post-service'; interface Params { params: Promise<{ id: string }>; } -export async function GET(_req: Request, { params }: Params) { +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { id } = await params; const post = await getPostById(id); if (!post) { - return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + return withRequestId(req, NextResponse.json({ error: 'Post not found' }, { status: 404 })); } - return NextResponse.json({ post }); -} + return withRequestId(req, NextResponse.json({ post })); +}, 'GET /api/posts/[id]'); -export async function DELETE(_req: Request, { params }: Params) { +export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Params; const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { id } = await params; try { - await deletePost(id, session!.user.id); - return NextResponse.json({ success: true }); + await deletePost(id, session.user.id); + return withRequestId(req, NextResponse.json({ success: true })); } catch { - return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + return withRequestId(req, NextResponse.json({ error: 'Post not found' }, { status: 404 })); } -} +}, 'DELETE /api/posts/[id]'); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 867f6eb..3fc64a6 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,15 +1,22 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; -import { createPost, getLatestPosts, getFollowingFeed, getTrendingPosts } from '@/backend/services/post-service'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { + createPost, + getLatestPosts, + getFollowingFeed, + getTrendingPosts, +} from '@/backend/services/post-service'; import { createPostSchema, feedFilterSchema, paginationSchema } from '@/lib/validations'; import { getPaginationParams } from '@/utils/pagination'; import { rateLimit } from '@/backend/middleware/rate-limit'; -export async function GET(req: Request) { +export const GET = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const { searchParams } = new URL(req.url); @@ -30,7 +37,7 @@ export async function GET(req: Request) { let result; switch (feedType) { case 'following': - result = await getFollowingFeed(session!.user.id, pagination); + result = await getFollowingFeed(session.user.id, pagination); break; case 'trending': result = await getTrendingPosts(pagination); @@ -39,29 +46,32 @@ export async function GET(req: Request) { result = await getLatestPosts(pagination); } - return NextResponse.json(result); -} + return withRequestId(req, NextResponse.json(result)); +}, 'GET /api/posts'); -export async function POST(req: Request) { +export const POST = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); - const rl = await rateLimit(session!.user.id, 'api'); - if (!rl.success) return rl.error; + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); const body = await req.json(); const parsed = createPostSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json( + { error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), ); } const post = await createPost({ - authorId: session!.user.id, + authorId: session.user.id, ...parsed.data, }); - return NextResponse.json({ post }, { status: 201 }); -} + return withRequestId(req, NextResponse.json({ post }, { status: 201 })); +}, 'POST /api/posts'); diff --git a/src/app/api/price-estimate/route.ts b/src/app/api/price-estimate/route.ts new file mode 100644 index 0000000..b66394c --- /dev/null +++ b/src/app/api/price-estimate/route.ts @@ -0,0 +1,49 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { priceEstimateSchema } from '@/lib/validations'; +import { estimatePrice } from '@/backend/services/price-estimator-service'; +import { getArtistByUserId } from '@/backend/services/artist-service'; +import { prisma } from '@/lib/prisma'; + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = priceEstimateSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + let hourlyRate = parsed.data.hourlyRate; + if (!hourlyRate) { + const artist = parsed.data.artistId + ? await prisma.artist.findUnique({ + where: { id: parsed.data.artistId }, + select: { hourlyRate: true }, + }) + : await getArtistByUserId(session.user.id); + hourlyRate = Number(artist?.hourlyRate ?? 150); + } + + const estimate = estimatePrice({ + hourlyRate, + size: parsed.data.size, + colorType: parsed.data.colorType, + placement: parsed.data.placement, + complexity: parsed.data.complexity, + styles: parsed.data.styles, + }); + + return withRequestId(req, NextResponse.json({ success: true, estimate })); +}, 'POST /api/price-estimate'); diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index b0c8183..e2d9c03 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -34,10 +34,7 @@ export async function POST(req: Request) { }); if (existing) { - return NextResponse.json( - { success: false, error: 'Already reported' }, - { status: 409 }, - ); + return NextResponse.json({ success: false, error: 'Already reported' }, { status: 409 }); } const report = await prisma.report.create({ diff --git a/src/app/api/search/artists/route.ts b/src/app/api/search/artists/route.ts index 5ed00f3..a5c0c36 100644 --- a/src/app/api/search/artists/route.ts +++ b/src/app/api/search/artists/route.ts @@ -28,10 +28,7 @@ export async function GET(req: Request) { page.success ? page.data.limit : 20, ); - const result = await searchArtists( - { q, location, style, shopId }, - pagination, - ); + const result = await searchArtists({ q, location, style, shopId }, pagination); return NextResponse.json({ success: true, ...result }); } diff --git a/src/app/api/shops/[id]/artists/route.ts b/src/app/api/shops/[id]/artists/route.ts index 71035fb..3f128a1 100644 --- a/src/app/api/shops/[id]/artists/route.ts +++ b/src/app/api/shops/[id]/artists/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; diff --git a/src/app/api/shops/[id]/bookings/route.ts b/src/app/api/shops/[id]/bookings/route.ts index 8e0dc28..ff762dd 100644 --- a/src/app/api/shops/[id]/bookings/route.ts +++ b/src/app/api/shops/[id]/bookings/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; diff --git a/src/app/api/shops/me/route.ts b/src/app/api/shops/me/route.ts index e2ba6cd..0f8944b 100644 --- a/src/app/api/shops/me/route.ts +++ b/src/app/api/shops/me/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; diff --git a/src/app/api/shops/route.ts b/src/app/api/shops/route.ts index 75a16c8..cc38492 100644 --- a/src/app/api/shops/route.ts +++ b/src/app/api/shops/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { getShops } from '@/backend/services/shop-service'; diff --git a/src/app/api/tattoos/[id]/like/route.ts b/src/app/api/tattoos/[id]/like/route.ts index 45923be..f2a92f2 100644 --- a/src/app/api/tattoos/[id]/like/route.ts +++ b/src/app/api/tattoos/[id]/like/route.ts @@ -1,14 +1,11 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { toggleLike } from '@/backend/services/tattoo-service'; -export async function POST( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { session, error } = await authGuard(); if (error) return error; diff --git a/src/app/api/tattoos/[id]/save/route.ts b/src/app/api/tattoos/[id]/save/route.ts index c90ff56..a251566 100644 --- a/src/app/api/tattoos/[id]/save/route.ts +++ b/src/app/api/tattoos/[id]/save/route.ts @@ -1,14 +1,11 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { saveTattoo } from '@/backend/services/tattoo-service'; -export async function POST( - _req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { session, error } = await authGuard(); if (error) return error; diff --git a/src/app/api/tattoos/[id]/view/route.ts b/src/app/api/tattoos/[id]/view/route.ts index 4ad965c..4de6142 100644 --- a/src/app/api/tattoos/[id]/view/route.ts +++ b/src/app/api/tattoos/[id]/view/route.ts @@ -1,13 +1,10 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { incrementViewCount } from '@/backend/services/tattoo-service'; import { rateLimit } from '@/backend/middleware/rate-limit'; -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> }, -) { +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { // rate limit by ip const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'; const rl = await rateLimit(ip, 'api'); diff --git a/src/app/api/tattoos/route.ts b/src/app/api/tattoos/route.ts index 2d6e2ce..495baf8 100644 --- a/src/app/api/tattoos/route.ts +++ b/src/app/api/tattoos/route.ts @@ -1,13 +1,9 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextRequest, NextResponse } from 'next/server'; import { requireRole } from '@/backend/middleware/role-guard'; import { authGuard } from '@/backend/middleware/auth-guard'; -import { - createTattoo, - getTattoos, - isTattooSlugTaken, -} from '@/backend/services/tattoo-service'; +import { createTattoo, getTattoos, isTattooSlugTaken } from '@/backend/services/tattoo-service'; import { createTattooSchema, tattooFilterSchema, paginationSchema } from '@/lib/validations'; import { slugify, slugWithCounter } from '@/utils/slugify'; import { getPaginationParams } from '@/utils/pagination'; @@ -31,9 +27,7 @@ export async function GET(req: NextRequest) { limit: searchParams.get('limit') ?? 20, }); - const { page, limit } = paginationResult.success - ? paginationResult.data - : { page: 1, limit: 20 }; + const { page, limit } = paginationResult.success ? paginationResult.data : { page: 1, limit: 20 }; const pagination = getPaginationParams(page, limit); @@ -90,19 +84,12 @@ export async function POST(req: NextRequest) { } if (!imageFile) { - return NextResponse.json( - { success: false, error: 'Image is required' }, - { status: 400 }, - ); + return NextResponse.json({ success: false, error: 'Image is required' }, { status: 400 }); } // process and upload image variants const imageBuffer = Buffer.from(await imageFile.arrayBuffer()); - const uploadResult = await uploadTattooImage( - imageBuffer, - imageFile.type, - authSession!.user.id, - ); + const uploadResult = await uploadTattooImage(imageBuffer, imageFile.type, authSession!.user.id); // generate unique slug let slug = slugify(validation.data.title); diff --git a/src/app/api/tattoos/trending/route.ts b/src/app/api/tattoos/trending/route.ts index 90c5e6b..b22d1fc 100644 --- a/src/app/api/tattoos/trending/route.ts +++ b/src/app/api/tattoos/trending/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextRequest, NextResponse } from 'next/server'; import { getTrendingTattoos } from '@/backend/services/tattoo-service'; diff --git a/src/app/api/uploads/scan-callback/route.ts b/src/app/api/uploads/scan-callback/route.ts new file mode 100644 index 0000000..c0d4916 --- /dev/null +++ b/src/app/api/uploads/scan-callback/route.ts @@ -0,0 +1,36 @@ +export const runtime = 'nodejs'; + +import { z } from 'zod'; +import { NextResponse } from 'next/server'; +import { withErrorHandler, errors } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { recordScanResult } from '@/lib/av-scan'; + +const callbackSchema = z.object({ + jobId: z.string().min(1), + status: z.enum(['CLEAN', 'INFECTED', 'ERROR', 'SKIPPED']), + scanResult: z.string().max(500).optional(), +}); + +// shared-secret callback from external av worker +export const POST = withErrorHandler(async (req: Request) => { + const expected = process.env.AV_SCAN_WEBHOOK_TOKEN; + if (expected) { + const auth = req.headers.get('authorization'); + if (auth !== `Bearer ${expected}`) { + return withRequestId(req, errors.unauthorized('invalid scan callback token')); + } + } + + const body = await req.json(); + const parsed = callbackSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid payload' }, { status: 400 }), + ); + } + + await recordScanResult(parsed.data.jobId, parsed.data.status, parsed.data.scanResult); + return withRequestId(req, NextResponse.json({ success: true })); +}, 'POST /api/uploads/scan-callback'); diff --git a/src/app/api/uploads/sign/route.ts b/src/app/api/uploads/sign/route.ts new file mode 100644 index 0000000..50b8a90 --- /dev/null +++ b/src/app/api/uploads/sign/route.ts @@ -0,0 +1,70 @@ +export const runtime = 'nodejs'; + +import { z } from 'zod'; +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId, requestLogger } from '@/backend/middleware/request-log'; +import { presignUploadUrl, buildUploadKey } from '@/lib/signed-upload'; +import { registerUploadForScan } from '@/lib/av-scan'; + +const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'] as const; +const MAX_BYTES = 10 * 1024 * 1024; +const SCOPES = ['tattoos', 'avatars', 'portfolio', 'ar-source'] as const; + +const bodySchema = z.object({ + scope: z.enum(SCOPES), + contentType: z.enum(ALLOWED), + fileExt: z.string().regex(/^[a-zA-Z0-9]{1,8}$/), + size: z.number().int().positive().max(MAX_BYTES), +}); + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'upload'); + if (!rl.success) return withRequestId(req, rl.error); + + const body = await req.json(); + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + const bucket = process.env.S3_BUCKET; + if (!bucket) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Storage not configured' }, { status: 503 }), + ); + } + + const key = buildUploadKey(parsed.data.scope, session.user.id, parsed.data.fileExt); + const presigned = presignUploadUrl({ + bucket, + key, + contentType: parsed.data.contentType, + contentLength: parsed.data.size, + }); + + // pre-register so client confirm step finds the row + scan can be dispatched after PUT completes + await registerUploadForScan({ + bucket, + key, + userId: session.user.id, + contentType: parsed.data.contentType, + sizeBytes: parsed.data.size, + }).catch((err) => { + requestLogger(req).warn({ err }, 'register upload failed'); + }); + + return withRequestId(req, NextResponse.json({ success: true, key, bucket, ...presigned })); +}, 'POST /api/uploads/sign'); diff --git a/src/app/api/user/avatar/route.ts b/src/app/api/user/avatar/route.ts index 4a9d8f5..6784cec 100644 --- a/src/app/api/user/avatar/route.ts +++ b/src/app/api/user/avatar/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse, type NextRequest } from 'next/server'; import { auth } from '@/lib/auth'; @@ -20,10 +20,7 @@ export async function POST(request: NextRequest) { const file = formData.get('avatar'); if (!file || !(file instanceof File)) { - return NextResponse.json( - { success: false, error: 'No file provided' }, - { status: 400 }, - ); + return NextResponse.json({ success: false, error: 'No file provided' }, { status: 400 }); } const buffer = Buffer.from(await file.arrayBuffer()); @@ -33,9 +30,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, data: { url: result.url } }); } catch (err) { const message = err instanceof Error ? err.message : 'Upload failed'; - return NextResponse.json( - { success: false, error: message }, - { status: 400 }, - ); + return NextResponse.json({ success: false, error: message }, { status: 400 }); } } diff --git a/src/app/api/user/onboarding/route.ts b/src/app/api/user/onboarding/route.ts index 6baf682..ef59698 100644 --- a/src/app/api/user/onboarding/route.ts +++ b/src/app/api/user/onboarding/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse, type NextRequest } from 'next/server'; import { auth } from '@/lib/auth'; @@ -34,7 +34,10 @@ export async function POST(request: NextRequest) { if (!/^[a-z0-9_-]+$/.test(username)) { return NextResponse.json( - { success: false, error: 'Username can only contain lowercase letters, numbers, hyphens, and underscores' }, + { + success: false, + error: 'Username can only contain lowercase letters, numbers, hyphens, and underscores', + }, { status: 400 }, ); } diff --git a/src/app/api/user/saved/route.ts b/src/app/api/user/saved/route.ts index 0ff0553..b9bb829 100644 --- a/src/app/api/user/saved/route.ts +++ b/src/app/api/user/saved/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextRequest, NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; @@ -16,9 +16,7 @@ export async function GET(req: NextRequest) { limit: searchParams.get('limit') ?? 20, }); - const { page, limit } = paginationResult.success - ? paginationResult.data - : { page: 1, limit: 20 }; + const { page, limit } = paginationResult.success ? paginationResult.data : { page: 1, limit: 20 }; const pagination = getPaginationParams(page, limit); const result = await getSavedTattoos(session!.user.id, pagination); diff --git a/src/app/api/users/[id]/follow/route.ts b/src/app/api/users/[id]/follow/route.ts index b8bdb1f..7d6133e 100644 --- a/src/app/api/users/[id]/follow/route.ts +++ b/src/app/api/users/[id]/follow/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; diff --git a/src/app/api/users/[id]/liked/route.ts b/src/app/api/users/[id]/liked/route.ts index 9fb8db8..9601fc2 100644 --- a/src/app/api/users/[id]/liked/route.ts +++ b/src/app/api/users/[id]/liked/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { getUserLikedPosts } from '@/backend/services/post-service'; diff --git a/src/app/api/users/[id]/posts/route.ts b/src/app/api/users/[id]/posts/route.ts index de80dcf..412c484 100644 --- a/src/app/api/users/[id]/posts/route.ts +++ b/src/app/api/users/[id]/posts/route.ts @@ -1,4 +1,4 @@ -export const runtime = "nodejs"; +export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { getUserPosts } from '@/backend/services/post-service'; diff --git a/src/app/api/users/me/route.ts b/src/app/api/users/me/route.ts index 097bbb4..2d3a3f4 100644 --- a/src/app/api/users/me/route.ts +++ b/src/app/api/users/me/route.ts @@ -2,14 +2,16 @@ export const runtime = 'nodejs'; import { NextResponse } from 'next/server'; import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; import { rateLimit } from '@/backend/middleware/rate-limit'; import { prisma } from '@/lib/prisma'; import { updateProfileSchema } from '@/lib/validations'; // get current user profile -export async function GET() { +export const GET = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const user = await prisma.user.findUnique({ where: { id: session.user.id }, @@ -23,26 +25,27 @@ export async function GET() { roles: true, favoriteStyles: true, createdAt: true, + totpEnabledAt: true, }, }); - return NextResponse.json({ success: true, user }); -} + return withRequestId(req, NextResponse.json({ success: true, user })); +}, 'GET /api/users/me'); // update current user profile -export async function PATCH(req: Request) { +export const PATCH = withErrorHandler(async (req: Request) => { const { session, error } = await authGuard(); - if (error) return error; + if (error) return withRequestId(req, error); const rl = await rateLimit(session.user.id, 'api'); - if (!rl.success) return rl.error; + if (!rl.success) return withRequestId(req, rl.error); const body = await req.json(); const parsed = updateProfileSchema.safeParse(body); if (!parsed.success) { - return NextResponse.json( - { success: false, error: parsed.error.issues[0].message }, - { status: 400 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: parsed.error.issues[0].message }, { status: 400 }), ); } @@ -56,9 +59,9 @@ export async function PATCH(req: Request) { select: { id: true }, }); if (existing) { - return NextResponse.json( - { success: false, error: 'Username is already taken' }, - { status: 409 }, + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Username is already taken' }, { status: 409 }), ); } } @@ -75,5 +78,5 @@ export async function PATCH(req: Request) { }, }); - return NextResponse.json({ success: true, user }); -} + return withRequestId(req, NextResponse.json({ success: true, user })); +}, 'PATCH /api/users/me'); diff --git a/src/app/api/users/search/route.ts b/src/app/api/users/search/route.ts new file mode 100644 index 0000000..1e08eec --- /dev/null +++ b/src/app/api/users/search/route.ts @@ -0,0 +1,41 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { prisma } from '@/lib/prisma'; +import { userSearchSchema } from '@/lib/validations'; + +// powers @mention autocomplete + people search +export const GET = withErrorHandler(async (req: Request) => { + const { error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { searchParams } = new URL(req.url); + const parsed = userSearchSchema.safeParse({ + q: searchParams.get('q') ?? '', + limit: searchParams.get('limit') ?? undefined, + }); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Invalid query' }, { status: 400 }), + ); + } + + const q = parsed.data.q.trim().replace(/^@/, ''); + const users = await prisma.user.findMany({ + where: { + OR: [ + { username: { contains: q, mode: 'insensitive' } }, + { name: { contains: q, mode: 'insensitive' } }, + ], + }, + select: { id: true, username: true, name: true, image: true }, + orderBy: { username: 'asc' }, + take: parsed.data.limit, + }); + + return withRequestId(req, NextResponse.json({ success: true, users })); +}, 'GET /api/users/search'); diff --git a/src/app/app/admin/audit/page.tsx b/src/app/app/admin/audit/page.tsx new file mode 100644 index 0000000..b7fa8ba --- /dev/null +++ b/src/app/app/admin/audit/page.tsx @@ -0,0 +1,80 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { listAuditEvents } from '@/backend/services/audit-log-service'; +import { getPaginationParams } from '@/utils/pagination'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Audit log — Admin', +}; + +interface SearchParams { + page?: string; + action?: string; +} + +export default async function AdminAuditPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const session = await auth(); + if (!session?.user?.roles?.includes('ADMIN')) redirect('/'); + + const sp = await searchParams; + const page = Number(sp.page ?? 1) || 1; + const pagination = getPaginationParams(page, 50); + + const { events, pagination: meta } = await listAuditEvents({}, pagination); + + return ( +
+
+ Showing {events.length} of {meta.total} events +
+ + + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + ))} + {events.length === 0 && ( + + + + )} + +
WhenActorActionTargetIP
+ {new Date(e.createdAt).toISOString().replace('T', ' ').slice(0, 19)} + + {e.actor.username ?? e.actor.email ?? e.actor.id.slice(0, 8)} + + {e.action} + + {e.targetType ? `${e.targetType}/${e.targetId ?? ''}` : '—'} + {e.ipAddress ?? '—'}
+ No audit events yet. +
+
+
+ ); +} diff --git a/src/app/app/admin/layout.tsx b/src/app/app/admin/layout.tsx new file mode 100644 index 0000000..7a5048f --- /dev/null +++ b/src/app/app/admin/layout.tsx @@ -0,0 +1,35 @@ +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { PageContainer } from '@/components/layouts/page-container'; + +const tabs = [ + { href: '/app/admin', label: 'Overview' }, + { href: '/app/admin/audit', label: 'Audit log' }, +]; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await auth(); + if (!session?.user) redirect('/login?callbackUrl=/app/admin'); + if (!session.user.roles?.includes('ADMIN')) redirect('/'); + + return ( + +
+

Admin

+ +
{children}
+
+
+ ); +} diff --git a/src/app/app/admin/page.tsx b/src/app/app/admin/page.tsx new file mode 100644 index 0000000..7a802f4 --- /dev/null +++ b/src/app/app/admin/page.tsx @@ -0,0 +1,38 @@ +import { GlassCard } from '@/components/ui/glass-card'; +import { prisma } from '@/lib/prisma'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Admin — AETCH', +}; + +// admin overview stats +export default async function AdminOverviewPage() { + const [users, posts, tattoos, reports, audit] = await Promise.all([ + prisma.user.count(), + prisma.post.count(), + prisma.tattoo.count(), + prisma.report.count({ where: { status: 'PENDING' } }), + prisma.adminAuditLog.count(), + ]); + + const cards = [ + { label: 'Users', value: users }, + { label: 'Posts', value: posts }, + { label: 'Tattoos', value: tattoos }, + { label: 'Reports (pending)', value: reports }, + { label: 'Audit events', value: audit }, + ]; + + return ( +
+ {cards.map((c) => ( + +
{c.label}
+
{c.value}
+
+ ))} +
+ ); +} diff --git a/src/app/app/ai/history/page.tsx b/src/app/app/ai/history/page.tsx index 5922ab1..ff633a8 100644 --- a/src/app/app/ai/history/page.tsx +++ b/src/app/app/ai/history/page.tsx @@ -20,12 +20,14 @@ export default function AIHistoryPage() { const data = await res.json(); const items = data.generations ?? []; - setGenerations((prev) => reset ? items : [...prev, ...items]); + setGenerations((prev) => (reset ? items : [...prev, ...items])); setHasMore(data.pagination?.hasNext ?? false); setLoading(false); }, []); - useEffect(() => { fetchHistory(1, true); }, [fetchHistory]); + useEffect(() => { + fetchHistory(1, true); + }, [fetchHistory]); const loadMore = () => { const next = page + 1; @@ -48,10 +50,7 @@ export default function AIHistoryPage() {
- + {!loading && hasMore && (
diff --git a/src/app/app/ar-preview/history/page.tsx b/src/app/app/ar-preview/history/page.tsx index 9dbb8d6..38538ad 100644 --- a/src/app/app/ar-preview/history/page.tsx +++ b/src/app/app/ar-preview/history/page.tsx @@ -2,7 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; import { PageContainer } from '@/components/layouts/page-container'; -import { PreviewHistoryCard, type PreviewData } from '@/components/features/ar/preview-history-card'; +import { + PreviewHistoryCard, + type PreviewData, +} from '@/components/features/ar/preview-history-card'; import { EmptyState } from '@/components/ui/empty-state'; import { GlassButton } from '@/components/ui/glass-button'; import { GlassSkeleton } from '@/components/ui/glass-skeleton'; @@ -23,12 +26,14 @@ export default function ARPreviewHistoryPage() { const data = await res.json(); const items = data.previews ?? []; - setPreviews((prev) => reset ? items : [...prev, ...items]); + setPreviews((prev) => (reset ? items : [...prev, ...items])); setHasMore(data.pagination?.hasNext ?? false); setLoading(false); }, []); - useEffect(() => { fetchHistory(1, true); }, [fetchHistory]); + useEffect(() => { + fetchHistory(1, true); + }, [fetchHistory]); const loadMore = () => { const next = page + 1; @@ -74,7 +79,9 @@ export default function ARPreviewHistoryPage() { description="Create your first AR tattoo preview to see it here." action={ - Create Preview + + Create Preview + } /> diff --git a/src/app/app/ar-preview/page.tsx b/src/app/app/ar-preview/page.tsx index 74f2e68..8624754 100644 --- a/src/app/app/ar-preview/page.tsx +++ b/src/app/app/ar-preview/page.tsx @@ -9,7 +9,10 @@ import { GlassCard } from '@/components/ui/glass-card'; import { useToast } from '@/components/ui/glass-toast'; import { BodyImageUploader } from '@/components/features/ar/body-image-uploader'; import { TattooSelector } from '@/components/features/ar/tattoo-selector'; -import { TattooPreviewCanvas, type PreviewTransform } from '@/components/features/ar/tattoo-preview-canvas'; +import { + TattooPreviewCanvas, + type PreviewTransform, +} from '@/components/features/ar/tattoo-preview-canvas'; import { PreviewControls } from '@/components/features/ar/preview-controls'; import { PreviewExportButton } from '@/components/features/ar/preview-export-button'; import { History, ScanEye } from 'lucide-react'; @@ -145,17 +148,15 @@ export default function ARPreviewPage() { > {BODY_PLACEMENTS.map((p) => ( - + ))} {canPreview && ( - + )}
diff --git a/src/app/app/artist/[artistSlug]/page.tsx b/src/app/app/artist/[artistSlug]/page.tsx index f4f1011..413b2d6 100644 --- a/src/app/app/artist/[artistSlug]/page.tsx +++ b/src/app/app/artist/[artistSlug]/page.tsx @@ -41,11 +41,7 @@ export default async function ArtistProfilePage({ params, searchParams }: Props)
- +

{artist.displayName}

@@ -54,17 +50,15 @@ export default async function ArtistProfilePage({ params, searchParams }: Props) {artist.user.username && (

@{artist.user.username}

)} - {artist.location && ( -

{artist.location}

- )} - {artist.bio && ( -

{artist.bio}

- )} + {artist.location &&

{artist.location}

} + {artist.bio &&

{artist.bio}

} {/* actions */}
- Book Now + + Book Now +
@@ -80,9 +74,7 @@ export default async function ArtistProfilePage({ params, searchParams }: Props)
{artist.hourlyRate && (
- - ${Number(artist.hourlyRate)} - + ${Number(artist.hourlyRate)} /hr
)} @@ -97,7 +89,9 @@ export default async function ArtistProfilePage({ params, searchParams }: Props)

Specialties

{artist.specialties.map((s) => ( - {s} + + {s} + ))}
@@ -110,7 +104,10 @@ export default async function ArtistProfilePage({ params, searchParams }: Props) {portfolio.tattoos.length > 0 ? ( <> - + ) : ( diff --git a/src/app/app/book/[artistSlug]/page.tsx b/src/app/app/book/[artistSlug]/page.tsx index 1f5b4e3..da28e8d 100644 --- a/src/app/app/book/[artistSlug]/page.tsx +++ b/src/app/app/book/[artistSlug]/page.tsx @@ -36,23 +36,19 @@ export default async function BookArtistPage({ params }: Props) { {/* artist header */}
- +

{artist.displayName}

- {artist.verified && Verified} + {artist.verified && ( + + Verified + + )}
- {artist.location && ( -

{artist.location}

- )} + {artist.location &&

{artist.location}

} {artist.hourlyRate && ( -

- ${Number(artist.hourlyRate)}/hr -

+

${Number(artist.hourlyRate)}/hr

)}
diff --git a/src/app/app/book/shop/[shopSlug]/page.tsx b/src/app/app/book/shop/[shopSlug]/page.tsx index a964ea9..84c5d8a 100644 --- a/src/app/app/book/shop/[shopSlug]/page.tsx +++ b/src/app/app/book/shop/[shopSlug]/page.tsx @@ -56,27 +56,23 @@ export default async function BookShopPage({ params }: Props) {

{shop.name}

- {shop.verified && Verified} + {shop.verified && ( + + Verified + + )}
- {location && ( -

{location}

- )} + {location &&

{location}

}

Book via Shop

-

- Choose an artist and fill out details below. -

+

Choose an artist and fill out details below.

- +
); diff --git a/src/app/app/coverup/page.tsx b/src/app/app/coverup/page.tsx new file mode 100644 index 0000000..6e0390b --- /dev/null +++ b/src/app/app/coverup/page.tsx @@ -0,0 +1,42 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { GlassCard } from '@/components/ui/glass-card'; +import { CoverupForm } from '@/components/features/coverup/coverup-form'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Coverup Finder — AETCH', + description: 'Generate coverup design ideas for an existing tattoo.', +}; + +export default async function CoverupPage() { + const session = await auth(); + if (!session?.user) redirect('/login?callbackUrl=/app/coverup'); + + if (!isFeatureEnabled('COVERUP_ENABLED')) { + return ( +
+ +

Coverup Finder

+

+ This feature is coming soon. Set FF_COVERUP=true to enable it. +

+
+
+ ); + } + + return ( +
+
+

Coverup Finder

+

+ Describe your existing tattoo and we'll generate coverup design ideas. +

+
+ +
+ ); +} diff --git a/src/app/app/dashboard/availability/page.tsx b/src/app/app/dashboard/availability/page.tsx index 3708fca..5390ea5 100644 --- a/src/app/app/dashboard/availability/page.tsx +++ b/src/app/app/dashboard/availability/page.tsx @@ -37,7 +37,7 @@ export default function AvailabilityPage() { }; const updateSlot = (dayOfWeek: number, field: 'startTime' | 'endTime', value: string) => { - setSlots(slots.map((s) => s.dayOfWeek === dayOfWeek ? { ...s, [field]: value } : s)); + setSlots(slots.map((s) => (s.dayOfWeek === dayOfWeek ? { ...s, [field]: value } : s))); }; const save = async () => { diff --git a/src/app/app/dashboard/bookings/page.tsx b/src/app/app/dashboard/bookings/page.tsx index b4237d3..9ce4513 100644 --- a/src/app/app/dashboard/bookings/page.tsx +++ b/src/app/app/dashboard/bookings/page.tsx @@ -55,7 +55,9 @@ export default function BookingsPage() { setLoading(false); }; - useEffect(() => { fetchBookings(); }, [filter]); + useEffect(() => { + fetchBookings(); + }, [filter]); const updateStatus = async (id: string, status: string) => { await fetch(`/api/bookings/${id}`, { @@ -126,9 +128,7 @@ export default function BookingsPage() { {b.tattooIdea && (

{b.tattooIdea}

)} - {b.placement && ( -

Placement: {b.placement}

- )} + {b.placement &&

Placement: {b.placement}

}
{b.status === 'PENDING' && ( diff --git a/src/app/app/dashboard/layout.tsx b/src/app/app/dashboard/layout.tsx index 5112bd6..d434839 100644 --- a/src/app/app/dashboard/layout.tsx +++ b/src/app/app/dashboard/layout.tsx @@ -3,11 +3,7 @@ import { auth } from '@/lib/auth'; import { PageContainer } from '@/components/layouts/page-container'; import { DashboardSidebar } from '@/components/features/dashboard/dashboard-sidebar'; -export default async function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await auth(); if (!session?.user) { diff --git a/src/app/app/dashboard/page.tsx b/src/app/app/dashboard/page.tsx index c7e8a58..2235f8a 100644 --- a/src/app/app/dashboard/page.tsx +++ b/src/app/app/dashboard/page.tsx @@ -28,8 +28,18 @@ export default async function DashboardOverviewPage() { const statCards = [ { label: 'Tattoos', value: stats.totalTattoos, icon: Images, href: '/app/dashboard/portfolio' }, - { label: 'Bookings', value: stats.totalBookings, icon: CalendarDays, href: '/app/dashboard/bookings' }, - { label: 'Pending', value: stats.pendingBookings, icon: Clock, href: '/app/dashboard/bookings?status=PENDING' }, + { + label: 'Bookings', + value: stats.totalBookings, + icon: CalendarDays, + href: '/app/dashboard/bookings', + }, + { + label: 'Pending', + value: stats.pendingBookings, + icon: Clock, + href: '/app/dashboard/bookings?status=PENDING', + }, { label: 'Reviews', value: stats.totalReviews, icon: Star, href: '#' }, ]; @@ -64,7 +74,9 @@ export default async function DashboardOverviewPage() {

Upcoming Bookings

- View all + + View all +
@@ -89,7 +101,9 @@ export default async function DashboardOverviewPage() {

- Details + + Details +
))} diff --git a/src/app/app/dashboard/portfolio/page.tsx b/src/app/app/dashboard/portfolio/page.tsx index 12974ab..76b1177 100644 --- a/src/app/app/dashboard/portfolio/page.tsx +++ b/src/app/app/dashboard/portfolio/page.tsx @@ -40,7 +40,9 @@ export default async function PortfolioPage({ searchParams }: Props) {

Manage your uploaded tattoos

- Upload Tattoo + + Upload Tattoo + @@ -51,7 +53,9 @@ export default async function PortfolioPage({ searchParams }: Props) { description="Upload your first tattoo to start building your portfolio." action={ - Upload Tattoo + + Upload Tattoo + } /> diff --git a/src/app/app/dashboard/upload/page.tsx b/src/app/app/dashboard/upload/page.tsx index 92382a3..3b10022 100644 --- a/src/app/app/dashboard/upload/page.tsx +++ b/src/app/app/dashboard/upload/page.tsx @@ -30,7 +30,8 @@ export default async function UploadPage() {

Artist Access Required

- Only artists can upload tattoos. If you're an artist, update your role in settings. + Only artists can upload tattoos. If you're an artist, update your role in + settings.

@@ -43,9 +44,7 @@ export default async function UploadPage() {

Upload Tattoo

-

- Share your work with the AETCH community. -

+

Share your work with the AETCH community.

diff --git a/src/app/app/design-system/page.tsx b/src/app/app/design-system/page.tsx index 8e9a8e0..edc5b8f 100644 --- a/src/app/app/design-system/page.tsx +++ b/src/app/app/design-system/page.tsx @@ -67,7 +67,9 @@ export default function DesignSystemPage() {
Caption -

The quick brown fox jumps over the lazy dog

+

+ The quick brown fox jumps over the lazy dog +

@@ -108,15 +110,23 @@ export default function DesignSystemPage() { Default Primary Ghost - Disabled + + Disabled +

Sizes

- Small - Medium - Large + + Small + + + Medium + + + Large +
@@ -178,7 +188,9 @@ export default function DesignSystemPage() { Success Warning Danger - Medium + + Medium + @@ -243,13 +255,21 @@ export default function DesignSystemPage() {
- Hover me (top) + + Hover me (top) + - Hover me (bottom) + + Hover me (bottom) + Dropdown} + trigger={ + + Dropdown + + } items={[ { id: 'edit', label: 'Edit Profile' }, { id: 'settings', label: 'Settings' }, diff --git a/src/app/app/error.tsx b/src/app/app/error.tsx index 10ecdc8..9b088ed 100644 --- a/src/app/app/error.tsx +++ b/src/app/app/error.tsx @@ -17,9 +17,7 @@ export default function AppError({ return (
-

- Something went wrong -

+

Something went wrong

An error occurred while loading this page. Please try again.

diff --git a/src/app/app/feed/page.tsx b/src/app/app/feed/page.tsx index 1d7e755..568439d 100644 --- a/src/app/app/feed/page.tsx +++ b/src/app/app/feed/page.tsx @@ -25,17 +25,20 @@ export default function FeedPage() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); - const fetchPosts = useCallback(async (pageNum: number, reset = false) => { - setLoading(true); - const params = new URLSearchParams({ type: feedType, page: String(pageNum) }); - const res = await fetch(`/api/posts?${params}`); - const data = await res.json(); - const newPosts = data.posts ?? []; + const fetchPosts = useCallback( + async (pageNum: number, reset = false) => { + setLoading(true); + const params = new URLSearchParams({ type: feedType, page: String(pageNum) }); + const res = await fetch(`/api/posts?${params}`); + const data = await res.json(); + const newPosts = data.posts ?? []; - setPosts((prev) => reset ? newPosts : [...prev, ...newPosts]); - setHasMore(data.pagination?.hasNext ?? false); - setLoading(false); - }, [feedType]); + setPosts((prev) => (reset ? newPosts : [...prev, ...newPosts])); + setHasMore(data.pagination?.hasNext ?? false); + setLoading(false); + }, + [feedType], + ); useEffect(() => { setPage(1); diff --git a/src/app/app/messages/page.tsx b/src/app/app/messages/page.tsx index 5868d4c..41b9048 100644 --- a/src/app/app/messages/page.tsx +++ b/src/app/app/messages/page.tsx @@ -15,10 +15,7 @@ export default async function MessagesPage() { const session = await auth(); if (!session?.user) redirect('/login?callbackUrl=/app/messages'); - const { conversations } = await getUserConversations( - session.user.id, - getPaginationParams(1, 50), - ); + const { conversations } = await getUserConversations(session.user.id, getPaginationParams(1, 50)); return ( @@ -31,19 +28,14 @@ export default async function MessagesPage() {
{/* conversation list */}
- +
{/* empty state for desktop */}
-

- Select a conversation to start chatting -

+

Select a conversation to start chatting

diff --git a/src/app/app/not-found.tsx b/src/app/app/not-found.tsx index f8d05d9..b1b4ce9 100644 --- a/src/app/app/not-found.tsx +++ b/src/app/app/not-found.tsx @@ -5,9 +5,7 @@ export default function AppNotFound() { return (
-

- Page Not Found -

+

Page Not Found

The page you're looking for doesn't exist.

diff --git a/src/app/app/post/[id]/page.tsx b/src/app/app/post/[id]/page.tsx index a72fa3c..2ce3d3e 100644 --- a/src/app/app/post/[id]/page.tsx +++ b/src/app/app/post/[id]/page.tsx @@ -69,9 +69,7 @@ export default function PostDetailPage() { -

- Comments ({comments.length}) -

+

Comments ({comments.length})

= { USER: 'Enthusiast', @@ -53,9 +52,7 @@ export default async function ProfilePage({ params }: Props) { className="mx-auto" />

{user.name ?? user.username}

- {user.username && ( -

@{user.username}

- )} + {user.username &&

@{user.username}

}
{roleLabel} {user.artist?.verified && Verified} diff --git a/src/app/app/saved/page.tsx b/src/app/app/saved/page.tsx index b61ee75..9dfb4b4 100644 --- a/src/app/app/saved/page.tsx +++ b/src/app/app/saved/page.tsx @@ -35,9 +35,7 @@ export default async function SavedTattoosPage({ searchParams }: Props) {

Saved Tattoos

-

- Your bookmarked tattoo inspiration. -

+

Your bookmarked tattoo inspiration.

{result.tattoos.length === 0 ? ( @@ -47,7 +45,9 @@ export default async function SavedTattoosPage({ searchParams }: Props) { description="Browse the gallery and save tattoos you love." action={ - Explore Gallery + + Explore Gallery + } /> diff --git a/src/app/app/settings/page.tsx b/src/app/app/settings/page.tsx index a8a65d5..6769482 100644 --- a/src/app/app/settings/page.tsx +++ b/src/app/app/settings/page.tsx @@ -104,21 +104,13 @@ export default function SettingsPage() {

Settings

{error && } - {success && ( -
- {success} -
- )} + {success &&
{success}
} {/* avatar */}

Profile Photo

- +
-

- JPG, PNG, or WebP. Max 2MB. -

+

JPG, PNG, or WebP. Max 2MB.

@@ -162,12 +152,7 @@ export default function SettingsPage() { placeholder="Tell us about yourself..." rows={3} /> - + Save changes diff --git a/src/app/app/shop-dashboard/artists/page.tsx b/src/app/app/shop-dashboard/artists/page.tsx index de5421b..79ce774 100644 --- a/src/app/app/shop-dashboard/artists/page.tsx +++ b/src/app/app/shop-dashboard/artists/page.tsx @@ -29,7 +29,9 @@ export default function ShopArtistsPage() { setLoading(false); }; - useEffect(() => { fetchData(); }, []); + useEffect(() => { + fetchData(); + }, []); const handleAdd = async () => { if (!artistSlug.trim() || !shopId) return; @@ -82,12 +84,7 @@ export default function ShopArtistsPage() { placeholder="Artist ID" className="flex-1" /> - + Add @@ -107,11 +104,7 @@ export default function ShopArtistsPage() { description="Add artists to your shop to get started." /> ) : ( - + )}
); diff --git a/src/app/app/shop-dashboard/bookings/page.tsx b/src/app/app/shop-dashboard/bookings/page.tsx index 2732855..0eca15d 100644 --- a/src/app/app/shop-dashboard/bookings/page.tsx +++ b/src/app/app/shop-dashboard/bookings/page.tsx @@ -99,13 +99,14 @@ export default function ShopBookingsPage() {

{new Date(b.date).toLocaleDateString('en-US', { - weekday: 'short', month: 'short', day: 'numeric', - hour: 'numeric', minute: '2-digit', + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', })}

-

- Artist: {b.artist.displayName} -

+

Artist: {b.artist.displayName}

{b.tattooIdea && (

{b.tattooIdea}

)} diff --git a/src/app/app/shop-dashboard/layout.tsx b/src/app/app/shop-dashboard/layout.tsx index 30ae189..b38fa85 100644 --- a/src/app/app/shop-dashboard/layout.tsx +++ b/src/app/app/shop-dashboard/layout.tsx @@ -3,11 +3,7 @@ import { auth } from '@/lib/auth'; import { PageContainer } from '@/components/layouts/page-container'; import { ShopDashboardSidebar } from '@/components/features/shops/shop-dashboard-sidebar'; -export default async function ShopDashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function ShopDashboardLayout({ children }: { children: React.ReactNode }) { const session = await auth(); if (!session?.user) { diff --git a/src/app/app/shop-dashboard/page.tsx b/src/app/app/shop-dashboard/page.tsx index 20b09cf..bf6ac91 100644 --- a/src/app/app/shop-dashboard/page.tsx +++ b/src/app/app/shop-dashboard/page.tsx @@ -21,9 +21,24 @@ export default async function ShopDashboardPage() { const stats = await getShopDashboardStats(shop.id); const statCards = [ - { label: 'Artists', value: stats.artistCount, icon: Users, href: '/app/shop-dashboard/artists' }, - { label: 'Bookings', value: stats.bookingCount, icon: CalendarDays, href: '/app/shop-dashboard/bookings' }, - { label: 'Pending', value: stats.pendingCount, icon: Clock, href: '/app/shop-dashboard/bookings' }, + { + label: 'Artists', + value: stats.artistCount, + icon: Users, + href: '/app/shop-dashboard/artists', + }, + { + label: 'Bookings', + value: stats.bookingCount, + icon: CalendarDays, + href: '/app/shop-dashboard/bookings', + }, + { + label: 'Pending', + value: stats.pendingCount, + icon: Clock, + href: '/app/shop-dashboard/bookings', + }, { label: 'Reviews', value: stats.reviewCount, icon: Star, href: '#' }, ]; @@ -56,13 +71,22 @@ export default async function ShopDashboardPage() {

Quick Actions

- + View public profile - + Manage artists - + Edit shop info
diff --git a/src/app/app/shop-dashboard/settings/page.tsx b/src/app/app/shop-dashboard/settings/page.tsx index 664870c..4db9e43 100644 --- a/src/app/app/shop-dashboard/settings/page.tsx +++ b/src/app/app/shop-dashboard/settings/page.tsx @@ -23,9 +23,15 @@ interface ShopForm { export default function ShopSettingsPage() { const { toast } = useToast(); const [form, setForm] = useState({ - name: '', description: '', address: '', - city: '', state: '', country: '', - phone: '', email: '', website: '', + name: '', + description: '', + address: '', + city: '', + state: '', + country: '', + phone: '', + email: '', + website: '', }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -91,21 +97,58 @@ export default function ShopSettingsPage() {
- update('name', e.target.value)} /> - update('description', e.target.value)} rows={3} /> + update('name', e.target.value)} + /> + update('description', e.target.value)} + rows={3} + />
- update('city', e.target.value)} /> - update('state', e.target.value)} /> + update('city', e.target.value)} + /> + update('state', e.target.value)} + />
- update('country', e.target.value)} /> - update('address', e.target.value)} /> + update('country', e.target.value)} + /> + update('address', e.target.value)} + />
- update('phone', e.target.value)} /> - update('email', e.target.value)} /> - update('website', e.target.value)} /> + update('phone', e.target.value)} + /> + update('email', e.target.value)} + /> + update('website', e.target.value)} + />
diff --git a/src/app/app/shop/[shopSlug]/page.tsx b/src/app/app/shop/[shopSlug]/page.tsx index 50e83db..d1583a9 100644 --- a/src/app/app/shop/[shopSlug]/page.tsx +++ b/src/app/app/shop/[shopSlug]/page.tsx @@ -84,17 +84,28 @@ export default async function ShopProfilePage({ params, searchParams }: Props) { {/* contact links */}
{shop.website && ( - + Website )} {shop.phone && ( - + {shop.phone} )} {shop.email && ( - + {shop.email} )} diff --git a/src/app/app/shops/[city]/page.tsx b/src/app/app/shops/[city]/page.tsx new file mode 100644 index 0000000..dabb7a8 --- /dev/null +++ b/src/app/app/shops/[city]/page.tsx @@ -0,0 +1,108 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { getShops } from '@/backend/services/shop-service'; +import { getPaginationParams } from '@/utils/pagination'; + +export const dynamic = 'force-dynamic'; + +interface PageProps { + params: Promise<{ city: string }>; + searchParams: Promise<{ page?: string }>; +} + +function decodeCity(slug: string): string { + return decodeURIComponent(slug).replace(/-/g, ' ').trim(); +} + +function titleCase(s: string): string { + return s + .split(' ') + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(' '); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { city } = await params; + const display = titleCase(decodeCity(city)); + return { + title: `Tattoo shops in ${display} — AETCH`, + description: `Browse verified tattoo shops in ${display}. Bookings, portfolios, reviews.`, + openGraph: { + title: `Tattoo shops in ${display}`, + description: `Discover the best tattoo studios in ${display} on AETCH.`, + }, + alternates: { canonical: `/app/shops/${city}` }, + }; +} + +export default async function CityShopsPage({ params, searchParams }: PageProps) { + const { city: rawCity } = await params; + const sp = await searchParams; + const cityName = decodeCity(rawCity); + if (!cityName) notFound(); + + const pagination = getPaginationParams(Number(sp.page ?? 1) || 1, 24); + const { shops, pagination: meta } = await getShops(pagination, { city: cityName }); + + // jsonld for seo + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: `Tattoo shops in ${titleCase(cityName)}`, + numberOfItems: meta.total, + itemListElement: shops.slice(0, 20).map((s, i) => ({ + '@type': 'ListItem', + position: i + 1, + item: { + '@type': 'TattooParlor', + name: s.name, + address: s.address ?? undefined, + url: `/app/shop/${s.slug}`, + }, + })), + }; + + return ( +
+