mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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=
|
|
||||||
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
pnpm run quick
|
pnpm run verify:fast
|
||||||
|
|||||||
+182
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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,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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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:
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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)"
|
|
||||||
@@ -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."
|
|
||||||
@@ -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`);
|
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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,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) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user