chore: cleanup project structure and resolve client type errors

- resolve all 69 client type-checking errors for stricter TS compliance
- remove obsolete type-check baseline scripts and files
- overhaul project root with updated README.md and new DEVELOPMENT.md
- migrate to unified baseline migration strategy and cleanup docker configs
- remove redundant production and dev-specific shell scripts
This commit is contained in:
neon
2026-03-25 07:14:51 -06:00
parent 4d58fbdfc6
commit 1418957b6f
51 changed files with 379 additions and 997 deletions
-1
View File
@@ -3,7 +3,6 @@
**/dist **/dist
**/*.log **/*.log
.env .env
.env.prod
client/.env client/.env
server/.env server/.env
docker/postgres/data docker/postgres/data
+42
View File
@@ -0,0 +1,42 @@
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=projectx
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
# Required for the initial /auth/setup endpoint (x-setup-token header)
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
# External/public URL used by emails and Kobo endpoints
APP_URL=https://projectx.example.com
# --- Optional overrides (uncomment to set) ---
# If you already run your own PostgreSQL, set DATABASE_URL and remove the
# postgres service from docker-compose.yml. Your instance must have the
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
# DATABASE_URL=postgres://user:password@yourhost:5432/projectx
# Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB)
# JWT token lifetimes
# JWT_EXPIRES_IN=15m
# JWT_REFRESH_EXPIRES_IN=7d
# CORS origin for the frontend when served from a different domain than APP_URL
# CLIENT_URL=https://app.example.com
# Staging directory (defaults to {BOOKS_PATH}/staging inside the container)
# STAGING_PATH=/staging
# AES-256 key for encrypting stored email credentials (generate: openssl rand -hex 32)
# EMAIL_ENCRYPTION_KEY=
# Log level (default: info). Set to debug for verbose output.
# LOG_LEVEL=info
-31
View File
@@ -1,31 +0,0 @@
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=projectx
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
EMAIL_ENCRYPTION_KEY=
# External/public URL used by emails and Kobo endpoints
APP_URL=https://projectx.example.com
# Optional client URL override
CLIENT_URL=https://projectx.example.com
# Optional SMTP config
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@projectx.local
# Optional API keys
GOOGLE_BOOKS_API_KEY=
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
- name: Compute changed scopes - name: Compute changed scopes
id: changes id: changes
if: github.event_name != 'workflow_dispatch' if: github.event_name != 'workflow_dispatch'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with: with:
filters: | filters: |
server: server:
@@ -152,7 +152,7 @@ jobs:
pnpm --filter server type-check pnpm --filter server type-check
else else
pnpm --filter @projectx/types build pnpm --filter @projectx/types build
pnpm run typecheck:client:baseline pnpm --filter client type-check
fi fi
- name: Prepare test report directory - name: Prepare test report directory
+1 -1
View File
@@ -2,10 +2,10 @@ node_modules/
dist/ dist/
.env .env
.env.local .env.local
.env.prod
client/.env client/.env
server/.env server/.env
*.local *.local
data
# OS # OS
.DS_Store .DS_Store
Regular → Executable
+1 -1
View File
@@ -1 +1 @@
pnpm run quick pnpm run verify:fast
+182
View File
@@ -0,0 +1,182 @@
# Development Guide
## Prerequisites
- Node.js >= 24
- pnpm >= 9
- Docker
---
## First-time Setup
```bash
pnpm setup
```
This creates `server/.env` from the example, installs all dependencies, starts PostgreSQL, runs migrations, and seeds baseline data.
If you prefer to do it manually:
```bash
pnpm run db:up # start PostgreSQL
cp server/.env.example server/.env # first time only
pnpm install
pnpm run db:migrate
pnpm run db:seed
```
---
## Running the App
```bash
pnpm dev
```
Starts the server and client concurrently with hot reload.
| Service | URL |
| ------- | ------------------------- |
| API | http://localhost:3000/api |
| Client | http://localhost:5173 |
If PostgreSQL stopped (e.g. after a machine restart):
```bash
pnpm run db:up # then pnpm dev
```
---
## Database Workflows
### Apply pending migrations
After pulling changes that include new migrations:
```bash
pnpm run db:migrate
```
### Add a schema change
1. Edit the relevant file in `server/src/db/schema/`
2. Generate a migration:
```bash
cd server && pnpm db:generate add-book-tags
```
This creates a new SQL file in `server/src/db/migrations/`. Review it before applying.
3. Apply it:
```bash
pnpm run db:migrate
```
> **Pre-launch rule:** Before the app ships, maintain a single baseline migration (`0000_*.sql`) rather than stacking incremental ones. When adding a schema change pre-launch:
>
> ```bash
> rm -rf server/src/db/migrations
> docker compose -f docker-compose.dev.yml down -v
> pnpm run db:up
> cd server && pnpm db:generate baseline
> pnpm run db:migrate
> ```
>
> After go-live, never wipe migrations. Every change gets its own incremental migration.
### Full database reset
Wipes all data, re-runs migrations, re-seeds, and clears generated files (covers, author images):
```bash
pnpm run db:reset
```
Use this when:
- Migrations are in a broken or inconsistent state
- A migration requires starting from scratch (e.g. column type change with no clean upgrade path)
- You want a known-clean local state
### Inspect the database
```bash
cd server && pnpm db:studio
```
Opens Drizzle Studio in your browser for browsing and editing tables directly.
---
## Testing
```bash
pnpm run test # all tests (server + client)
pnpm run test:server # server unit tests only
pnpm run test:client # client unit tests only
pnpm run test:e2e:smoke # e2e smoke suite (requires running DB)
```
Watch mode while working on a specific area:
```bash
cd server && pnpm test:watch
```
---
## Code Quality
Run before pushing:
```bash
pnpm run verify:fast # lint + typecheck + tests (same as pre-push hook)
pnpm run verify # above + e2e smoke (run before opening a PR)
```
Individual checks:
```bash
pnpm run lint:check # check for lint errors
pnpm run lint:fix # auto-fix lint errors
pnpm run typecheck # server + client baseline typecheck
pnpm run typecheck:full # server + full client typecheck (slower)
```
Format:
```bash
cd server && npx prettier --write .
cd client && npx prettier --write .
```
---
## Common Scenarios
### Pulled changes that include a new migration
```bash
pnpm run db:migrate
```
### Pulled changes that changed the DB schema significantly and your local state is stale
```bash
pnpm run db:reset
```
### Something is wrong and you want a completely clean slate
```bash
pnpm run db:reset
```
If that is not enough (e.g. Docker volume is corrupted):
```bash
docker compose -f docker-compose.dev.yml down -v
pnpm run db:up
pnpm run db:migrate
pnpm run db:seed
```
+1 -1
View File
@@ -44,7 +44,7 @@ COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /tmp && chown -R node:node /books /tmp RUN chmod +x /app/entrypoint.sh && mkdir -p /books /staging /data /tmp && chown -R node:node /books /staging /data /tmp
USER node USER node
-16
View File
@@ -1,16 +0,0 @@
FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY client/package.json ./client/
COPY server/package.json ./server/
COPY packages/types/package.json ./packages/types/
RUN pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000 5173
CMD ["pnpm", "dev"]
-8
View File
@@ -1,8 +0,0 @@
FROM node:24-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json package-lock.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
+2 -4
View File
@@ -101,15 +101,14 @@ onMounted(fetchPage)
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap">Action</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Description</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground">Description</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">IP</th> <th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">IP</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">Country</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="divide-y divide-border">
<tr v-if="loading"> <tr v-if="loading">
<td colspan="6" class="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td> <td colspan="5" class="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td>
</tr> </tr>
<tr v-else-if="entries.length === 0"> <tr v-else-if="entries.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-sm text-muted-foreground">No audit logs found</td> <td colspan="5" class="px-4 py-8 text-center text-sm text-muted-foreground">No audit logs found</td>
</tr> </tr>
<tr <tr
v-else v-else
@@ -130,7 +129,6 @@ onMounted(fetchPage)
</td> </td>
<td class="px-4 py-2.5 text-muted-foreground max-w-xs truncate">{{ entry.description }}</td> <td class="px-4 py-2.5 text-muted-foreground max-w-xs truncate">{{ entry.description }}</td>
<td class="px-4 py-2.5 text-muted-foreground font-mono text-xs hidden md:table-cell">{{ entry.ip ?? '-' }}</td> <td class="px-4 py-2.5 text-muted-foreground font-mono text-xs hidden md:table-cell">{{ entry.ip ?? '-' }}</td>
<td class="px-4 py-2.5 text-muted-foreground text-xs hidden md:table-cell">{{ entry.countryCode ?? '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -59,7 +59,8 @@ watch(representativeCandidates, (list) => {
}) })
const activeCandidate = computed( const activeCandidate = computed(
() => representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0], () =>
representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0] ?? props.initialCandidate,
) )
const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicksForProvider, copyAll, copyMissing, buildPatch, hasCopied } = const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicksForProvider, copyAll, copyMissing, buildPatch, hasCopied } =
@@ -6,6 +6,7 @@ import { useMetadataDiff } from '../useMetadataDiff'
describe('useMetadataDiff', () => { describe('useMetadataDiff', () => {
const mockCurrent: MetadataSource = { const mockCurrent: MetadataSource = {
title: 'Original Title', title: 'Original Title',
subtitle: 'Original Subtitle',
authors: ['Original Author'], authors: ['Original Author'],
genres: ['Genre 1'], genres: ['Genre 1'],
description: 'Original Description', description: 'Original Description',
@@ -17,6 +18,9 @@ describe('useMetadataDiff', () => {
seriesIndex: 1, seriesIndex: 1,
isbn10: '1234567890', isbn10: '1234567890',
isbn13: '1234567890123', isbn13: '1234567890123',
narrators: [],
durationSeconds: null,
abridged: null,
} }
const mockCandidate1: MetadataCandidate = { const mockCandidate1: MetadataCandidate = {
@@ -37,8 +41,8 @@ describe('useMetadataDiff', () => {
} }
const providers = [ const providers = [
{ key: 'google' as MetadataProviderKey, label: 'Google Books' }, { key: 'google' as MetadataProviderKey, label: 'Google Books', identifiable: true },
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads' }, { key: 'goodreads' as MetadataProviderKey, label: 'Goodreads', identifiable: true },
] ]
it('initializes with fields from active provider', () => { it('initializes with fields from active provider', () => {
@@ -42,8 +42,10 @@ function onDrop(index: number) {
if (draggedIndex.value === null || draggedIndex.value === index) return if (draggedIndex.value === null || draggedIndex.value === index) return
const items = [...draft.value] const items = [...draft.value]
const [moved] = items.splice(draggedIndex.value, 1) const [moved] = items.splice(draggedIndex.value, 1)
items.splice(index, 0, moved) if (moved) {
draft.value = items items.splice(index, 0, moved)
draft.value = items
}
draggedIndex.value = null draggedIndex.value = null
dragOverIndex.value = null dragOverIndex.value = null
} }
@@ -27,7 +27,7 @@ export function useFoliateInput(
function handleTouchStart(e: TouchEvent) { function handleTouchStart(e: TouchEvent) {
if (e.touches.length !== 1) return if (e.touches.length !== 1) return
const touch = e.touches[0] const touch = e.touches[0]!
touchStartX = touch.clientX touchStartX = touch.clientX
touchStartY = touch.clientY touchStartY = touch.clientY
touchStartTime = Date.now() touchStartTime = Date.now()
@@ -39,7 +39,7 @@ export function useFoliateInput(
function handleTouchMove(e: TouchEvent, doc: Document) { function handleTouchMove(e: TouchEvent, doc: Document) {
if (e.touches.length !== 1) return if (e.touches.length !== 1) return
const touch = e.touches[0] const touch = e.touches[0]!
const deltaX = Math.abs(touch.clientX - touchStartX) const deltaX = Math.abs(touch.clientX - touchStartX)
const deltaY = Math.abs(touch.clientY - touchStartY) const deltaY = Math.abs(touch.clientY - touchStartY)
const selection = doc.defaultView?.getSelection() const selection = doc.defaultView?.getSelection()
@@ -67,7 +67,7 @@ export function useFoliateInput(
} }
if (!isTextSelectionInProgress && e.changedTouches.length === 1) { if (!isTextSelectionInProgress && e.changedTouches.length === 1) {
const touch = e.changedTouches[0] const touch = e.changedTouches[0]!
const deltaX = touch.clientX - touchStartX const deltaX = touch.clientX - touchStartX
const deltaY = Math.abs(touch.clientY - touchStartY) const deltaY = Math.abs(touch.clientY - touchStartY)
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { themes } from '../constants/themes' import { themes } from '../constants/themes'
import type { ThemeMode } from '../constants/themes' import type { Theme, ThemeMode } from '../constants/themes'
import type { FoliateRenderer } from './useFoliate' import type { FoliateRenderer } from './useFoliate'
export interface ReaderState { export interface ReaderState {
@@ -62,7 +62,7 @@ export function useReaderState() {
flow: flow.value, flow: flow.value,
})) }))
const currentTheme = computed(() => themes.find((t) => t.name === themeName.value) ?? themes[0]) const currentTheme = computed<Theme>(() => themes.find((t) => t.name === themeName.value) ?? themes[0]!)
const activeMode = computed<ThemeMode>(() => { const activeMode = computed<ThemeMode>(() => {
const theme = currentTheme.value const theme = currentTheme.value
@@ -209,7 +209,7 @@ export function useReaderState() {
function applyToRenderer(renderer: FoliateRenderer): void { function applyToRenderer(renderer: FoliateRenderer): void {
if (!renderer) return if (!renderer) return
const s = state.value const s = state.value
renderer.setAttribute('max-column-count', s.maxColumnCount) renderer.setAttribute('max-column-count', String(s.maxColumnCount))
renderer.setAttribute('gap', `${s.gap * 100}%`) renderer.setAttribute('gap', `${s.gap * 100}%`)
renderer.setAttribute('max-inline-size', `${s.maxInlineSize}px`) renderer.setAttribute('max-inline-size', `${s.maxInlineSize}px`)
renderer.setAttribute('max-block-size', `${s.maxBlockSize}px`) renderer.setAttribute('max-block-size', `${s.maxBlockSize}px`)
+1 -1
View File
@@ -1,5 +1,5 @@
export function stripFragment(href: string): string { export function stripFragment(href: string): string {
return href.split('#')[0] return href.split('#')[0]!
} }
export function formatDate(dateStr: string): string { export function formatDate(dateStr: string): string {
@@ -28,8 +28,8 @@ export function usePdfFind(textLayerMap: Ref<Map<number, HTMLElement>>) {
} }
function isWordBoundary(text: string, start: number, len: number): boolean { function isWordBoundary(text: string, start: number, len: number): boolean {
const before = start === 0 ? ' ' : text[start - 1] const before = start === 0 ? ' ' : (text[start - 1] ?? ' ')
const after = start + len >= text.length ? ' ' : text[start + len] const after = start + len >= text.length ? ' ' : (text[start + len] ?? ' ')
return /\W/.test(before) && /\W/.test(after) return /\W/.test(before) && /\W/.test(after)
} }
@@ -89,7 +89,7 @@ export function usePdfRenderer(
if (typeof item !== 'object' || item === null || !('str' in item)) continue if (typeof item !== 'object' || item === null || !('str' in item)) continue
const { str, transform, height } = item as PdfTextItem const { str, transform, height } = item as PdfTextItem
if (!str.trim()) continue if (!str.trim()) continue
const [a, b, , , e, f] = transform const [a, b, , , e, f] = transform as [number, number, number, number, number, number]
const span = document.createElement('span') const span = document.createElement('span')
span.textContent = str span.textContent = str
span.style.cssText = span.style.cssText =
@@ -580,11 +580,13 @@ onMounted(() => {
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
<MetadataDiffPanel <MetadataDiffPanel
:current="currentSource" :current="currentSource"
:candidate="selectedCandidate" :candidates="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:initial-candidate="selectedCandidate"
:filtered-results="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:providers="providers" :providers="providers"
:back-label="diffSource === 'fetched' ? 'Back' : 'Results'" :back-label="diffSource === 'fetched' ? 'Back' : 'Results'"
:current-cover-url="currentStagingCoverUrl" :current-cover-url="currentStagingCoverUrl"
:field-sources="diffSource === 'fetched' ? (file.fetchedMetadataSources ?? undefined) : undefined" :provider-ids="(file.fetchedMetadataSources as any) ?? undefined"
@back="backFromDiff" @back="backFromDiff"
@apply="handleApply" @apply="handleApply"
/> />
+5 -3
View File
@@ -30,11 +30,13 @@ async function attemptRefresh(): Promise<void> {
} }
export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> { export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> {
const res = await rawFetch(input, init) const isRetry = init?._isRetry
const { _isRetry: _, ...rest } = init ?? {}
const res = await rawFetch(input, rest)
if (res.status !== 401) return res if (res.status !== 401) return res
if (init?._isRetry) { if (isRetry) {
_onAuthFailure?.() _onAuthFailure?.()
throw new Error('Session expired') throw new Error('Session expired')
} }
@@ -52,5 +54,5 @@ export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRe
throw new Error('Session expired') throw new Error('Session expired')
} }
return rawFetch(input, { ...init, _isRetry: true }) return api(input, { ...rest, _isRetry: true })
} }
-53
View File
@@ -1,53 +0,0 @@
# Client typecheck baseline
# Auto-generated by: pnpm run typecheck:client:baseline:update
# One diagnostic per line. Do not edit manually unless necessary.
src/features/book/components/detail/tabs/EditMetadataTab.vue(394,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(400,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(404,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(411,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(415,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(421,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(427,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(431,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(437,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(115,20): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(117,19): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(124,69): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(126,105): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(127,35): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(107,90): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(117,140): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(131,99): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(151,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(161,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(47,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(58,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(68,94): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(7,9): error TS2739: Type '{ title: string; authors: string[]; genres: string[]; description: string; publisher: string; publishedYear: number; language: string; pageCount: number; seriesName: string; seriesIndex: number; isbn10: string; isbn13: string; }' is missing the following properties from type 'MetadataSource': subtitle, narrators, durationSeconds, abridged
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(79,104): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(91,121): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
src/features/staging/components/StagingFileSheet.vue(581,12): error TS2345: Argument of type '{ current: MetadataSource; candidate: { provider: MetadataProviderKey; providerId: string; title: string; subtitle?: string | undefined; authors?: string[] | undefined; description?: string | undefined; publisher?: string | undefined; ... 15 more ...; comicMetadata?: { ...; } | undefined; }; ... 5 more ...; onBack: ...' is not assignable to parameter of type '{ readonly current: MetadataSource; readonly candidates: MetadataCandidate[]; readonly initialCandidate: MetadataCandidate; ... 6 more ...; readonly onApply?: ((args_0: { ...; }) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
-161
View File
@@ -1,161 +0,0 @@
> client@0.0.0 type-check /Users/developer/Workspace/projectx/client
> vue-tsc --build
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
Type 'unknown' is not assignable to type 'RawTocItem'.
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
Type 'unknown' is not assignable to type 'RawTocItem'.
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
 ELIFECYCLE  Command failed with exit code 2.
+24
View File
@@ -0,0 +1,24 @@
name: projectx-dev
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-projectx}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx}
POSTGRES_DB: ${POSTGRES_DB:-projectx}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projectx"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
-106
View File
@@ -1,106 +0,0 @@
name: projectx
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER?POSTGRES_USER is required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB?POSTGRES_DB is required}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER?POSTGRES_USER is required} -d ${POSTGRES_DB?POSTGRES_DB is required}",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
logging: *default-logging
migrate:
image: ${APP_IMAGE?APP_IMAGE is required}
restart: "no"
init: true
environment:
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
command: ["node", "dist/scripts/migrate.js"]
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging: *default-logging
app:
image: ${APP_IMAGE?APP_IMAGE is required}
restart: unless-stopped
init: true
ports:
- "${APP_PORT:-3000}:3000"
environment:
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
JWT_SECRET: ${JWT_SECRET?JWT_SECRET is required}
APP_URL: ${APP_URL?APP_URL is required}
NODE_ENV: production
BOOKS_PATH: /books
STAGING_PATH: /books/staging
PORT: 3000
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
CLIENT_URL: ${CLIENT_URL:-http://localhost:5173}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-noreply@projectx.local}
EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:-}
GOOGLE_BOOKS_API_KEY: ${GOOGLE_BOOKS_API_KEY:-}
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
- books_data:/books
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
healthcheck:
test:
[
"CMD-SHELL",
'node -e "fetch(''http://127.0.0.1:3000/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging: *default-logging
volumes:
postgres_data:
books_data:
+52 -16
View File
@@ -1,25 +1,61 @@
name: projectx-dev name: projectx
services: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-projectx} POSTGRES_USER: ${POSTGRES_USER?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB:-projectx} POSTGRES_DB: ${POSTGRES_DB?required}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - ./data/postgres:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U projectx"] test:
interval: 5s [
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER?required} -d ${POSTGRES_DB?required}",
]
interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 10
start_period: 20s
app:
volumes: image: ${APP_IMAGE?required}
postgres_data: restart: unless-stopped
init: true
ports:
- "${APP_PORT:-3000}:3000"
environment:
POSTGRES_USER: ${POSTGRES_USER?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required}
JWT_SECRET: ${JWT_SECRET?required}
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./books:/books
- ./staging:/staging
- ./data:/data
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
healthcheck:
test:
[
"CMD-SHELL",
'node -e "fetch(''http://127.0.0.1:''+(process.env.PORT||3000)+''/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
-84
View File
@@ -1,84 +0,0 @@
# Production Deployment (Docker Compose)
This project's production Compose stack is intentionally separate from local development.
## 1) Configure production environment
Create `.env.prod` from the example and set real values:
```bash
cp .env.prod.example .env.prod
```
Required fields:
- `APP_IMAGE` (immutable tag or digest from CI, for example `ghcr.io/<owner>/<repo>:sha-<commit>`)
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_DB`
- `JWT_SECRET`
- `APP_URL`
## 2) Deploy
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
What happens:
- `postgres` starts with `pgvector` enabled via init SQL.
- `migrate` runs once (`node dist/scripts/migrate.js`) and must complete successfully.
- `app` starts only after DB health is `healthy` and migration service exits successfully.
## 3) Roll forward
1. Update `APP_IMAGE` in `.env.prod` to the new immutable image.
2. Pull + restart:
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
## 4) Backup strategy
Create a compressed custom-format Postgres backup:
```bash
pnpm db:backup:prod
```
By default this writes to `local/backups/`.
Recommended cadence:
- Daily backups
- Keep at least 7 daily + 4 weekly + 3 monthly snapshots
- Store a copy off-host
## 5) Restore strategy
Restore into the live DB:
```bash
pnpm db:restore:prod -- local/backups/<file>.dump
```
Run a non-destructive restore test into a temporary DB:
```bash
pnpm db:restore:test:prod -- local/backups/<file>.dump
```
Run the restore test at least monthly so backup integrity is continuously verified.
## 6) CI image pipeline
Workflow: `.github/workflows/container-image.yml`
On `main` and tag pushes it:
- builds the Docker image
- pushes immutable tags to GHCR (`sha-*`)
+4 -14
View File
@@ -2,26 +2,19 @@
"name": "projectx", "name": "projectx",
"private": true, "private": true,
"scripts": { "scripts": {
"help": "node ./scripts/help.mjs",
"guide": "node ./scripts/help.mjs",
"setup": "bash ./scripts/setup.sh", "setup": "bash ./scripts/setup.sh",
"doctor": "bash ./scripts/doctor.sh",
"dev": "concurrently --names \"types,server,client\" --prefix-colors \"cyan,blue,green\" \"pnpm --filter @projectx/types dev\" \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently --names \"types,server,client\" --prefix-colors \"cyan,blue,green\" \"pnpm --filter @projectx/types dev\" \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "cd client && npm run dev -- --host", "dev:client": "cd client && npm run dev -- --host",
"dev:server": "cd server && npm run start:dev", "dev:server": "cd server && npm run start:dev",
"quick": "pnpm run verify:fast",
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test", "verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke", "verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke", "verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
"build:client": "pnpm --filter client build", "build:client": "pnpm --filter client build",
"build:server": "pnpm --filter server build", "build:server": "pnpm --filter server build",
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client:baseline", "typecheck": "pnpm run typecheck:server && pnpm run typecheck:client",
"typecheck:full": "pnpm run typecheck:server && pnpm run typecheck:client", "typecheck:full": "pnpm run typecheck:server && pnpm run typecheck:client",
"typecheck:server": "pnpm --filter server type-check", "typecheck:server": "pnpm --filter server type-check",
"typecheck:client": "pnpm --filter client type-check", "typecheck:client": "pnpm --filter client type-check",
"typecheck:client:baseline": "node ./scripts/client-typecheck-baseline.mjs check",
"typecheck:client:baseline:update": "node ./scripts/client-typecheck-baseline.mjs update",
"lint": "pnpm run lint:check",
"lint:check": "pnpm --filter server lint:check && pnpm --filter client lint:check", "lint:check": "pnpm --filter server lint:check && pnpm --filter client lint:check",
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix", "lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
"format": "pnpm --filter server format && pnpm --filter client format", "format": "pnpm --filter server format && pnpm --filter client format",
@@ -33,12 +26,9 @@
"coverage": "pnpm run coverage:server && pnpm run coverage:client", "coverage": "pnpm run coverage:server && pnpm run coverage:client",
"coverage:server": "pnpm --filter server test:cov", "coverage:server": "pnpm --filter server test:cov",
"coverage:client": "pnpm --filter client test:cov --run", "coverage:client": "pnpm --filter client test:cov --run",
"db:up": "docker compose up -d --wait", "db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"prod:up": "docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait", "prod:up": "docker compose up -d --wait",
"prod:down": "docker compose --env-file .env.prod -f docker-compose.prod.yml down", "prod:down": "docker compose down",
"db:backup:prod": "bash ./scripts/db/backup-prod.sh .env.prod",
"db:restore:prod": "bash ./scripts/db/restore-prod.sh .env.prod",
"db:restore:test:prod": "bash ./scripts/db/restore-test-prod.sh .env.prod",
"db:migrate": "pnpm --filter server db:migrate", "db:migrate": "pnpm --filter server db:migrate",
"db:reset": "bash ./scripts/db/reset.sh", "db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh", "db:seed": "bash ./scripts/db/seed.sh",
-1
View File
@@ -108,7 +108,6 @@ export interface AuditLogEntry {
resourceId: number | null; resourceId: number | null;
description: string; description: string;
ip: string | null; ip: string | null;
countryCode: string | null;
meta: Record<string, unknown> | null; meta: Record<string, unknown> | null;
createdAt: string; createdAt: string;
} }
-120
View File
@@ -1,120 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const mode = process.argv[2] ?? 'check'
const rootDir = process.cwd()
const baselinePath = resolve(rootDir, 'client/typecheck-baseline.txt')
function runClientTypecheck() {
const result = spawnSync('pnpm', ['--filter', 'client', 'exec', 'vue-tsc', '--build', '--pretty', 'false'], {
cwd: rootDir,
encoding: 'utf8',
})
if (result.error) {
throw result.error
}
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
const diagnostics = parseDiagnostics(combinedOutput)
return { result, combinedOutput, diagnostics }
}
function parseDiagnostics(output) {
const regex = /^(.*\.(?:ts|tsx|vue)\(\d+,\d+\): error TS\d+: .*)$/gm
const rows = []
for (const match of output.matchAll(regex)) {
const raw = String(match[1] ?? '').trim()
rows.push(normalizeDiagnostic(raw))
}
return [...new Set(rows)].sort((a, b) => a.localeCompare(b))
}
function normalizeDiagnostic(row) {
const rootPrefix = `${rootDir.replaceAll('\\', '/')}/`
let normalized = row.replaceAll('\\', '/')
normalized = normalized.split(`${rootPrefix}client/`).join('')
normalized = normalized.split(rootPrefix).join('')
if (normalized.startsWith('client/')) normalized = normalized.slice('client/'.length)
return normalized
}
function readBaseline() {
if (!existsSync(baselinePath)) return []
return readFileSync(baselinePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
function writeBaseline(diagnostics) {
mkdirSync(dirname(baselinePath), { recursive: true })
const header = [
'# Client typecheck baseline',
'# Auto-generated by: pnpm run typecheck:client:baseline:update',
'# One diagnostic per line. Do not edit manually unless necessary.',
'',
]
writeFileSync(baselinePath, `${header.join('\n')}${diagnostics.join('\n')}\n`, 'utf8')
}
if (mode === 'update') {
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status !== 0 && diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
writeBaseline(diagnostics)
console.log(`Updated ${baselinePath} with ${diagnostics.length} diagnostics.`)
process.exit(0)
}
if (mode !== 'check') {
console.error(`Unknown mode "${mode}". Use "check" or "update".`)
process.exit(2)
}
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status === 0) {
console.log('Client typecheck is clean.')
process.exit(0)
}
if (diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
const baseline = readBaseline()
if (baseline.length === 0) {
console.error(`No baseline found at ${baselinePath}.`)
console.error('Run: pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const baselineSet = new Set(baseline)
const newDiagnostics = diagnostics.filter((line) => !baselineSet.has(line))
if (newDiagnostics.length > 0) {
console.error(`Found ${newDiagnostics.length} new client type errors not present in baseline:`)
for (const line of newDiagnostics) {
console.error(`- ${line}`)
}
console.error('If these are intentional and accepted, refresh baseline:')
console.error('pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const resolvedCount = baseline.filter((line) => !diagnostics.includes(line)).length
console.log(`No new client type errors. Baseline size: ${baseline.length}. Current: ${diagnostics.length}. Resolved: ${resolvedCount}.`)
if (resolvedCount > 0) {
console.log('You can refresh baseline to capture improvements:')
console.log('pnpm run typecheck:client:baseline:update')
}
process.exit(0)
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
OUT_DIR="${2:-$ROOT_DIR/local/backups}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
mkdir -p "$OUT_DIR"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_file="$OUT_DIR/projectx-${POSTGRES_DB}-${timestamp}.dump"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc >"$backup_file"
echo "Backup created: $backup_file"
+8 -8
View File
@@ -8,16 +8,16 @@ POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}" POSTGRES_DB="${POSTGRES_DB:-projectx}"
echo "Ensuring PostgreSQL is running..." echo "Ensuring PostgreSQL is running..."
docker compose up -d --wait postgres docker compose -f docker-compose.dev.yml up -d --wait postgres
echo "Resetting database schema..." echo "Resetting database schema..."
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS vector;" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS vector;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;" docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;"
echo "Re-applying migrations..." echo "Re-applying migrations..."
pnpm run db:migrate pnpm run db:migrate
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();" >/dev/null
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges
echo "Restore complete: $BACKUP_FILE -> $POSTGRES_DB"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
test_db="${POSTGRES_DB}_restore_test_$(date -u +%Y%m%d%H%M%S)"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE \"$test_db\";" >/dev/null
cleanup() {
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$test_db\";" >/dev/null || true
}
trap cleanup EXIT
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$test_db" --no-owner --no-privileges >/dev/null
table_count="$(
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$test_db" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';"
)"
echo "Restore test succeeded: $BACKUP_FILE -> $test_db (${table_count} public tables)"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "Checking toolchain..."
node -v
pnpm -v
echo "Checking environment files..."
[[ -f server/.env.example ]] || { echo "Missing server/.env.example"; exit 1; }
[[ -f server/.env ]] || { echo "Missing server/.env (run: pnpm setup)"; exit 1; }
echo "Checking PostgreSQL container..."
docker compose ps postgres
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
echo "Checking local scripts..."
[[ -d scripts/db ]] || { echo "Missing scripts/db helpers"; exit 1; }
[[ -f scripts/help.mjs ]] || { echo "Missing scripts/help.mjs"; exit 1; }
echo "Doctor check passed."
-24
View File
@@ -1,24 +0,0 @@
const lines = [
'ProjectX local workflow',
'',
'Core commands',
' pnpm setup # one-time bootstrap on a machine',
' pnpm dev # run server + client for daily work',
' pnpm verify # default local quality gate before push',
'',
'Optional',
' pnpm quick # faster local confidence loop',
' pnpm verify:strict # aspirational strict gate (format + full typecheck)',
' pnpm guide # print this command reference',
' pnpm typecheck:client:baseline:update # refresh accepted client TS baseline',
' pnpm doctor # environment health checks',
' pnpm coverage # coverage reports for server + client',
'',
'Database helpers',
' pnpm db:up',
' pnpm db:migrate',
' pnpm db:reset',
' pnpm db:seed',
];
process.stdout.write(`${lines.join('\n')}\n`);
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# Seeds synthetic reading sessions and daily stats for the first user so that
# all user-statistics charts (heatmap, peak hours, completion timeline, etc.)
# show realistic data out of the box.
#
# Safe to run multiple times - sessions are deduplicated by session_id and
# daily stats are upserted. Running again adds more data on top.
#
# Usage: ./scripts/seed/reading-sessions.sh [USER_ID]
# USER_ID defaults to 1.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
TARGET_USER_ID="${1:-1}"
echo "Seeding reading sessions for user_id=${TARGET_USER_ID}..."
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
DO \$\$
DECLARE
v_user_id int := ${TARGET_USER_ID};
v_day date;
v_file_id int;
v_library_id int;
v_sessions int;
v_i int;
v_hour int;
v_duration int;
v_started timestamptz;
v_ended timestamptz;
v_end_pct real;
v_delta real;
v_session_id text;
v_dow int;
v_file_ids int[];
v_total_sessions int := 0;
v_total_completions int := 0;
BEGIN
-- Verify user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
RAISE EXCEPTION 'User with id % not found', v_user_id;
END IF;
-- Verify there are book files to work with
IF NOT EXISTS (SELECT 1 FROM book_files LIMIT 1) THEN
RAISE EXCEPTION 'No book files found - scan a library first';
END IF;
-- Pick up to 40 real file ids to spread sessions across
SELECT array_agg(id ORDER BY random()) INTO v_file_ids
FROM (SELECT id FROM book_files ORDER BY random() LIMIT 40) t;
-- Spread books.added_at across the past 3 years so Format Share Over Time
-- chart has data across multiple months instead of a single spike today.
UPDATE books SET added_at = now() - (random() * interval '3 years')
WHERE added_at > now() - interval '7 days';
-- Insert sessions across the past 365 days
FOR v_day IN
SELECT generate_series(
current_date - interval '364 days',
current_date,
interval '1 day'
)::date
LOOP
v_dow := extract(dow from v_day); -- 0=Sun 6=Sat
-- Weekends: 1-3 sessions. Weekdays: 0-2 (30% chance of 0).
IF v_dow IN (0, 6) THEN
v_sessions := 1 + floor(random() * 3)::int;
ELSIF random() < 0.30 THEN
v_sessions := 0;
ELSE
v_sessions := 1 + floor(random() * 2)::int;
END IF;
FOR v_i IN 1..v_sessions LOOP
v_file_id := v_file_ids[1 + floor(random() * array_length(v_file_ids, 1))::int];
SELECT b.library_id INTO v_library_id
FROM book_files bf JOIN books b ON b.id = bf.book_id
WHERE bf.id = v_file_id;
-- Realistic hour distribution: mostly evenings, some mornings/afternoons
IF random() < 0.60 THEN
v_hour := 18 + floor(random() * 6)::int; -- 18-23 evening
ELSIF random() < 0.50 THEN
v_hour := 7 + floor(random() * 3)::int; -- 7-9 morning
ELSE
v_hour := 12 + floor(random() * 4)::int; -- 12-15 afternoon
END IF;
v_started := (v_day + make_interval(hours => v_hour, mins => floor(random() * 60)::int))::timestamptz;
v_duration := 600 + floor(random() * 3000)::int; -- 10-60 min
v_ended := v_started + make_interval(secs => v_duration);
v_delta := round((1 + random() * 8)::numeric, 4); -- 1-9% progress
-- ~15% of sessions are completions (every 7th deterministically by loop)
IF (v_total_sessions % 7) = 0 THEN
v_end_pct := 100;
v_total_completions := v_total_completions + 1;
ELSE
v_end_pct := round((10 + random() * 80)::numeric, 2);
END IF;
v_session_id := md5(v_user_id::text || v_file_id::text || v_started::text || v_i::text);
INSERT INTO reading_sessions
(user_id, book_file_id, session_id, started_at, ended_at, duration_seconds, progress_delta, end_progress)
VALUES
(v_user_id, v_file_id, v_session_id, v_started, v_ended, v_duration, v_delta, v_end_pct)
ON CONFLICT (session_id) DO NOTHING;
-- Real-time upsert of daily stats (mirrors what the server does on session save)
INSERT INTO user_reading_daily_stats
(user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
VALUES
(v_user_id, v_library_id, v_day, v_duration, v_delta, 1, now())
ON CONFLICT (user_id, library_id, day) DO UPDATE SET
reading_seconds = user_reading_daily_stats.reading_seconds + excluded.reading_seconds,
progress_delta = user_reading_daily_stats.progress_delta + excluded.progress_delta,
sessions_count = user_reading_daily_stats.sessions_count + 1,
updated_at = now();
v_total_sessions := v_total_sessions + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Done: % sessions inserted (% completions)', v_total_sessions, v_total_completions;
END;
\$\$;
EOSQL
echo "Reading session seed complete."
+1 -3
View File
@@ -13,12 +13,10 @@ echo "Installing dependencies..."
pnpm install pnpm install
echo "Starting PostgreSQL..." echo "Starting PostgreSQL..."
docker compose up -d --wait docker compose -f docker-compose.dev.yml up -d --wait
echo "Applying migrations..." echo "Applying migrations..."
pnpm run db:migrate pnpm run db:migrate
echo "Seeding baseline data..." echo "Seeding baseline data..."
pnpm run db:seed pnpm run db:seed
pnpm run doctor
+4 -5
View File
@@ -13,15 +13,14 @@ JWT_REFRESH_EXPIRES_IN=7d
SETUP_BOOTSTRAP_TOKEN= SETUP_BOOTSTRAP_TOKEN=
# Storage # Storage
# App data directory for cover images and generated files. # App data directory for covers, author images, user avatars, and cache.
# This path resolves relative to server/ (where the process runs). # This path resolves relative to server/ (where the process runs).
# ../local/data points to <project-root>/local/data. # ../local/data points to <project-root>/local/data.
# In production/Docker this is typically an absolute path like /data.
BOOKS_PATH=../local/data BOOKS_PATH=../local/data
# Staging (optional — defaults to {BOOKS_PATH}/staging) # Staging directory where files are dropped for auto-import.
# Directory where staging files are uploaded and watched for new drops. # Defaults to {BOOKS_PATH}/staging if unset.
# STAGING_PATH=../local/data/staging # STAGING_PATH=../local/staging
# OIDC (optional — configure via admin settings UI; these are just for reference) # OIDC (optional — configure via admin settings UI; these are just for reference)
# OIDC config is stored in the database (app_settings table, key: oidc_config) # OIDC config is stored in the database (app_settings table, key: oidc_config)
-6
View File
@@ -1,6 +0,0 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "start:dev"]
+9
View File
@@ -1,3 +1,12 @@
#!/bin/sh #!/bin/sh
set -e set -e
if [ -z "$DATABASE_URL" ]; then
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}"
fi
export BOOKS_PATH="${BOOKS_PATH:-/data}"
export STAGING_PATH="${STAGING_PATH:-/staging}"
node dist/scripts/migrate.js
exec node dist/main.js exec node dist/main.js
+1 -3
View File
@@ -60,13 +60,12 @@
"fast-xml-parser": "^5.5.9", "fast-xml-parser": "^5.5.9",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"fastify": "^5.8.4", "fastify": "^5.8.4",
"geoip-lite": "^2.0.1",
"jose": "^6.2.2", "jose": "^6.2.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"music-metadata": "^11.12.3", "music-metadata": "^11.12.3",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
"node-unrar-js": "^2.0.2", "node-unrar-js": "^2.0.2",
"nodemailer": "^8.0.3", "nodemailer": "^8.0.4",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
@@ -89,7 +88,6 @@
"@swc/core": "^1.15.21", "@swc/core": "^1.15.21",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/geoip-lite": "^1.4.4",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/nodemailer": "^6.4.23", "@types/nodemailer": "^6.4.23",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
+2 -2
View File
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, storageConfig } from './config/config'; import { appConfig, authConfig, dbConfig, emailConfig, storageConfig } from './config/config';
import { validateEnv } from './config/env.validation'; import { validateEnv } from './config/env.validation';
import { loggerConfig } from './common/logger.config'; import { loggerConfig } from './common/logger.config';
import { CommonModule } from './common/common.module'; import { CommonModule } from './common/common.module';
@@ -58,7 +58,7 @@ import { AuditModule } from './modules/audit/audit.module';
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
validate: validateEnv, validate: validateEnv,
load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig], load: [appConfig, dbConfig, authConfig, storageConfig, emailConfig],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
DbModule, DbModule,
+1 -7
View File
@@ -1,9 +1,7 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
export const appConfig = registerAs('app', () => ({ export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development', nodeEnv: process.env.NODE_ENV ?? 'development',
logLevel: process.env.LOG_LEVEL,
appUrl: process.env.APP_URL ?? 'http://localhost:5173', appUrl: process.env.APP_URL ?? 'http://localhost:5173',
})); }));
@@ -19,14 +17,10 @@ export const authConfig = registerAs('auth', () => ({
})); }));
export const storageConfig = registerAs('storage', () => ({ export const storageConfig = registerAs('storage', () => ({
booksPath: process.env.BOOKS_PATH ?? '/data/books', booksPath: process.env.BOOKS_PATH ?? '/data',
stagingPath: process.env.STAGING_PATH, stagingPath: process.env.STAGING_PATH,
})); }));
export const externalApiConfig = registerAs('externalApi', () => ({
googleBooksApiKey: process.env.GOOGLE_BOOKS_API_KEY ?? '',
}));
export const emailConfig = registerAs('email', () => ({ export const emailConfig = registerAs('email', () => ({
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '', encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
})); }));
+2 -4
View File
@@ -2,8 +2,7 @@ import { z } from 'zod';
const envSchema = z.object({ const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000), DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL').optional(),
DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
JWT_SECRET: z JWT_SECRET: z
.string() .string()
.min(16, 'JWT_SECRET must be at least 16 characters') .min(16, 'JWT_SECRET must be at least 16 characters')
@@ -14,10 +13,9 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('15m'), JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'), JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
SETUP_BOOTSTRAP_TOKEN: z.string().optional(), SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
BOOKS_PATH: z.string().default('/data/books'), BOOKS_PATH: z.string().default('/data'),
STAGING_PATH: z.string().optional(), STAGING_PATH: z.string().optional(),
CLIENT_URL: z.string().url().optional(), CLIENT_URL: z.string().url().optional(),
GOOGLE_BOOKS_API_KEY: z.string().default(''),
APP_URL: z.string().url().default('http://localhost:5173'), APP_URL: z.string().url().default('http://localhost:5173'),
EMAIL_ENCRYPTION_KEY: z.string().optional(), EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
@@ -10,7 +10,6 @@ CREATE TABLE "audit_log" (
"resource_id" integer, "resource_id" integer,
"description" text NOT NULL, "description" text NOT NULL,
"ip" varchar(45), "ip" varchar(45),
"country_code" char(2),
"meta" jsonb, "meta" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL "created_at" timestamp DEFAULT now() NOT NULL
); );
@@ -1,5 +1,5 @@
{ {
"id": "044c15cb-ab99-4b1f-b88f-3c025644dec7", "id": "2e38901e-f8a5-4f93-8564-e0f8f44e1883",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -56,12 +56,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"country_code": {
"name": "country_code",
"type": "char(2)",
"primaryKey": false,
"notNull": false
},
"meta": { "meta": {
"name": "meta", "name": "meta",
"type": "jsonb", "type": "jsonb",
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1774336038252, "when": 1774487725854,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
+1 -2
View File
@@ -1,4 +1,4 @@
import { bigserial, char, index, integer, jsonb, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'; import { bigserial, index, integer, jsonb, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth'; import { users } from './auth';
@@ -13,7 +13,6 @@ export const auditLog = pgTable(
resourceId: integer('resource_id'), resourceId: integer('resource_id'),
description: text('description').notNull(), description: text('description').notNull(),
ip: varchar('ip', { length: 45 }), ip: varchar('ip', { length: 45 }),
countryCode: char('country_code', { length: 2 }),
meta: jsonb('meta'), meta: jsonb('meta'),
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}, },
@@ -1,5 +1,4 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import * as geoip from 'geoip-lite';
import { AUDIT_EVENT, AuditEventPayload, AuditEventsService } from './audit-events.service'; import { AUDIT_EVENT, AuditEventPayload, AuditEventsService } from './audit-events.service';
import { AuditRepository, AuditLogQuery } from './audit.repository'; import { AuditRepository, AuditLogQuery } from './audit.repository';
@@ -30,8 +29,6 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
} }
private handleAuditEvent(payload: AuditEventPayload): void { private handleAuditEvent(payload: AuditEventPayload): void {
const countryCode = payload.ip ? (geoip.lookup(payload.ip)?.country ?? null) : null;
this.auditRepository this.auditRepository
.insert({ .insert({
userId: payload.userId, userId: payload.userId,
@@ -41,7 +38,6 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
resourceId: payload.resourceId ?? null, resourceId: payload.resourceId ?? null,
description: payload.description, description: payload.description,
ip: payload.ip ?? null, ip: payload.ip ?? null,
countryCode,
meta: payload.meta ?? null, meta: payload.meta ?? null,
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
+6
View File
@@ -33,6 +33,12 @@ async function runMigrations() {
}); });
try { try {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "vector";
`);
const migrationsFolder = resolveMigrationsFolder(); const migrationsFolder = resolveMigrationsFolder();
await migrate(drizzle(pool), { migrationsFolder }); await migrate(drizzle(pool), { migrationsFolder });