Compare commits

...
10 Commits
Author SHA1 Message Date
devil 49cfe2d8e0 fix(schema): cascade-delete VideoConfig/LintResult/BulkJobItem/VideoPlaylist with their Video
Channel import crashed on the hard-delete purge path (videos confirmed
gone from YouTube for 30+ days): prisma.video.deleteMany() hit a FK
violation on VideoConfig_videoId_fkey. All four required relations
pointing at Video had no onDelete behavior set, defaulting to RESTRICT
- any of the four would have hit the same crash, not just VideoConfig.
First real channel import against a real, aged dataset is what
surfaced this; nothing had exercised the 30-day hard-delete path before.
2026-08-11 17:14:20 +02:00
devil 0ac1ca3b08 fix(scripts): disable MSYS path translation in migrate-local-db-to-server.sh
pg_dump inside the container failed with "could not open output file
C:/Users/.../Temp/studioflow_migration_....dump" - Git Bash/MSYS was
rewriting the /tmp/... argument into a Windows path before handing it
to docker.exe, even though it's meant to be interpreted inside the
Linux container. MSYS_NO_PATHCONV=1 stops that translation.
2026-08-11 17:08:09 +02:00
devil b8c4cebad5 chore(scripts): add local-to-server DB migration script
One-time tool: dumps the local dev Postgres, scp's it to the server,
and restores it into the remote postgres container over SSH - drops
and recreates the target database, so it prompts for an explicit YES
before touching the remote side. Connection details are placeholders
(SSH_USER/SSH_HOST/etc.) filled in via env vars or direct edits, never
committed with real values.
2026-08-11 16:45:24 +02:00
devil baf827df29 fix(auth): set supplementalVideoIds on first-login channel creation
upsertGoogleUser's nested channels.create (the auto-provisioning path
for a brand new team's first Google login) never set
supplementalVideoIds, a required String[] column with no DB default
(dropped intentionally in 20260608000002 to match the Prisma schema).
channel-import.service.ts already sets it explicitly on its create path
- this path was just missed, and nothing had exercised a real first
login against Postgres until now.
2026-08-11 16:33:22 +02:00
devil ba409ca53b fix(backend): compiled output was nested under dist/src, not dist/
api container crashed on boot: Cannot find module '/app/dist/main.js'.
Root cause: tsconfig.json had no rootDir, and prisma/make-admin.ts (run
separately via ts-node, never part of the Nest build) was swept into
the nest build compilation since there was no exclude either - so tsc
inferred the output root as the project root and nested everything
under dist/src/ instead of the flat dist/main.js the Dockerfile,
package.json scripts, and compose's worker command all assume.

Also excludes tsconfig.tsbuildinfo from the Docker build context - a
stale local incremental-build cache file was leaking in via `COPY . .`
(only dist/node_modules were dockerignored) and made nest build skip
emitting .js files entirely on a from-scratch container build once the
rootDir fix invalidated its cached signature.
2026-08-11 16:10:44 +02:00
devil 811c14ee73 fix(infrastructure): don't let a slow/unhealthy api block deploy
Portainer's compose up blocks on frontend's api: condition:
service_healthy dependency and, on timeout, tears down everything it
just created - so an unhealthy api container never stuck around long
enough to pull logs from. Relaxed to service_started (frontend doesn't
need api ready at container-start), and gave the api healthcheck more
runway (60s start_period, 10 retries @ 10s) in case it's just slow to
boot rather than crashing.
2026-08-11 15:53:06 +02:00
devil ba8c7185a7 fix(infrastructure): use the actual Traefik network name (proxy)
The external network on the target server is named "proxy", not
traefik-network as originally assumed - deploy was failing with
"network traefik-network not found". Updated the compose file and
both the infra README and vault deployment doc to match.
2026-08-11 15:43:06 +02:00
devil b122ab4ac0 chore(infrastructure): force image re-pull on redeploy for Portainer
pull_policy: always on the four studioflow services (migrate, api,
worker, frontend) so redeploying the stack in Portainer actually fetches
the latest pushed image instead of reusing a stale local layer for the
mutable `latest`/IMAGE_TAG reference. Third-party pinned images
(postgres, redis) are left on default pull behavior.
2026-08-11 15:18:04 +02:00
devil d6ff99e8a4 feat(infrastructure): pull pre-built images from Gitea registry
migrate/api/worker/frontend now reference
git.devils.zone/devil/youtube-studio-flow-{backend,frontend}:\${IMAGE_TAG}
instead of building on the deploy host. Adds scripts/build-and-push.sh
to build+tag (git short SHA + latest) and push both images from a dev
machine, and updates infrastructure/README.md with the pull-based
deploy sequence.
2026-08-11 14:18:30 +02:00
devil b51c79e889 fix(frontend): resolve build-blocking lint and type errors
next build runs ESLint + full type-check and fails on errors (not just
warnings) - the production build was broken. Fixes: unused imports/vars,
any-typed diff/preference lookups replaced with the actual union/Record
types, ternary-as-statement flagged by no-unused-expressions, unescaped
JSX entities, a UserPreferences interface missing two fields the video
editor already reads/writes at runtime, and an import of
ColumnVisibilityState which @tanstack/react-table does not export
(the real name is VisibilityState).
2026-08-11 14:18:26 +02:00
21 changed files with 239 additions and 65 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules/
# Build output
backend/dist/
backend/tsconfig.tsbuildinfo
frontend/.next/
frontend/tsconfig.tsbuildinfo
+1
View File
@@ -1,5 +1,6 @@
node_modules
dist
tsconfig.tsbuildinfo
.env
.env.development
.env.production
@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "BulkJobItem" DROP CONSTRAINT "BulkJobItem_videoId_fkey";
-- DropForeignKey
ALTER TABLE "LintResult" DROP CONSTRAINT "LintResult_videoId_fkey";
-- DropForeignKey
ALTER TABLE "VideoConfig" DROP CONSTRAINT "VideoConfig_videoId_fkey";
-- DropForeignKey
ALTER TABLE "VideoPlaylist" DROP CONSTRAINT "VideoPlaylist_videoId_fkey";
-- AddForeignKey
ALTER TABLE "VideoConfig" ADD CONSTRAINT "VideoConfig_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LintResult" ADD CONSTRAINT "LintResult_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+4 -4
View File
@@ -174,7 +174,7 @@ enum LintStatus {
model VideoConfig {
id String @id @default(cuid())
videoId String @unique
video Video @relation(fields: [videoId], references: [id])
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
templateId String?
blockOrder Json
blockOverrides Json
@@ -312,7 +312,7 @@ model SavedView {
model LintResult {
id String @id @default(cuid())
videoId String
video Video @relation(fields: [videoId], references: [id])
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
ruleCode String
severity LintSeverity
targetField String?
@@ -369,7 +369,7 @@ model BulkJobItem {
bulkJobId String
bulkJob BulkJob @relation(fields: [bulkJobId], references: [id])
videoId String
video Video @relation(fields: [videoId], references: [id])
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
beforeSnapshot Json?
afterSnapshot Json?
status String @default("pending")
@@ -415,7 +415,7 @@ model VideoPlaylist {
videoId String
playlistId String
position Int?
video Video @relation(fields: [videoId], references: [id])
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
playlist Playlist @relation(fields: [playlistId], references: [id])
@@id([videoId, playlistId])
+1
View File
@@ -67,6 +67,7 @@ export class AuthService {
youtubeChannelId,
name: channelName,
uploadsPlaylistId,
supplementalVideoIds: [],
youtubeAccessToken: this.encrypt(accessToken),
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
+3 -1
View File
@@ -9,6 +9,7 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
@@ -17,5 +18,6 @@
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
},
"exclude": ["node_modules", "dist", "prisma"]
}
@@ -17,7 +17,7 @@ The production setup is a single-host Docker Compose deployment using **Traefik*
| `postgres` | postgres:16-alpine | Database — bound to `127.0.0.1:5432` (not public) |
| `redis` | redis:7-alpine | BullMQ queues — `noeviction` policy, AOF persistence |
**SSL / routing**: Traefik handles TLS termination with automatic Let's Encrypt certificates. Both API (`/api` prefix) and frontend run on the same domain — Traefik routes by path prefix. The `traefik-network` external network must exist before deploy.
**SSL / routing**: Traefik handles TLS termination with automatic Let's Encrypt certificates. Both API (`/api` prefix) and frontend run on the same domain — Traefik routes by path prefix. The `proxy` external network (the host's existing Traefik network) must exist before deploy.
**Build**: Multi-stage Dockerfile (`node:22-alpine`). Builder compiles TypeScript; runner installs prod-only deps. The Prisma CLI is copied from builder stage so the `migrate` service can run schema migrations.
@@ -45,8 +45,6 @@ const RULE_CATALOG = [
{ code: 'REMOTE_CONFLICT', severity: 'ERROR', label: 'Remote conflict', desc: 'The video was modified on YouTube after the last sync — local and remote diverged.' },
] as const;
type RuleCode = typeof RULE_CATALOG[number]['code'];
// ── Helpers ───────────────────────────────────────────────────────────────────
function SevBadge({ sev }: { sev: string }) {
@@ -156,7 +154,7 @@ export default function LintingPage() {
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
@@ -172,7 +170,7 @@ export default function LintingPage() {
function toggleFix(id: string) {
setExpandedFix((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
@@ -483,7 +483,7 @@ export default function SettingsPage() {
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Publishing Schedule</h2>
<p className={styles.sectionDesc}>
Define the time slots when videos are allowed to publish. Used by the "Next free slot" feature in the video editor.
Define the time slots when videos are allowed to publish. Used by the &quot;Next free slot&quot; feature in the video editor.
</p>
<div className={styles.scheduleBox}>
@@ -581,7 +581,7 @@ export default function SettingsPage() {
/>
<span>Show Canva link in video editor</span>
<span className={styles.toggleDesc}>
Adds a link next to YouTube Studio that searches Canva for the video's Game Title.
Adds a link next to YouTube Studio that searches Canva for the video&apos;s Game Title.
</span>
</label>
) : (
@@ -601,7 +601,7 @@ export default function SettingsPage() {
/>
<span>Show deleted videos tab</span>
<span className={styles.toggleDesc}>
Reveals a "Deleted" tab in the video list for videos removed from YouTube. They are kept locally for 30 days before being permanently deleted.
Reveals a &quot;Deleted&quot; tab in the video list for videos removed from YouTube. They are kept locally for 30 days before being permanently deleted.
</span>
</label>
) : (
@@ -38,8 +38,7 @@ function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
const label = FIELD_LABELS[field] ?? field;
const diff = item.diff as any;
const d = diff[field];
const d = item.diff[field as keyof typeof item.diff];
if (!d) return null;
if (field === 'tags') {
@@ -47,7 +46,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
<tr>
<td className={styles.diffField}>{label}</td>
<td className={styles.diffBefore}>{(d.before as string[]).join(', ') || '—'}</td>
<td className={styles.diffAfter}><TagsDiff before={d.before} after={d.after} /></td>
<td className={styles.diffAfter}><TagsDiff before={d.before as string[]} after={d.after as string[]} /></td>
</tr>
);
}
@@ -60,7 +59,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
</tr>
);
}
const fmt = (v: any) => {
const fmt = (v: string | number | boolean | string[] | null | undefined) => {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
return String(v);
@@ -13,7 +13,7 @@ import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-ki
import {
ArrowLeft, ExternalLink, Loader2, AlertCircle, CheckCircle2,
Save, Tag, Shield, Calendar, Hash, RefreshCw, RotateCcw,
Plus, Minus, Layers3, X, Upload, CloudOff, Eye, EyeOff, Search, CalendarPlus,
Layers3, X, Upload, CloudOff, Eye, EyeOff, Search, CalendarPlus,
ChevronDown, ChevronUp,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
@@ -34,7 +34,7 @@ import SortableSection from '@/components/shared/SortableSection';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const PRIVACY_LABELS = { PUBLIC: 'Public', PRIVATE: 'Private', UNLISTED: 'Unlisted' } as const;
const PRIVACY_LABELS: Record<string, string> = { PUBLIC: 'Public', PRIVATE: 'Private', UNLISTED: 'Unlisted' };
function utcToLocalInput(utcIso: string): string {
const d = new Date(utcIso);
+5 -13
View File
@@ -3,8 +3,8 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useRouter } from 'next/navigation';
import { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, X, Plus } from 'lucide-react';
import { type SortingState, type ColumnVisibilityState } from '@tanstack/react-table';
import { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, Plus } from 'lucide-react';
import { type SortingState, type VisibilityState } from '@tanstack/react-table';
import VideoTable, { VideoRow, VIDEO_COLUMN_DEFS } from '@/components/video-table/VideoTable';
import ColumnPicker from '@/components/shared/ColumnPicker';
import SavedViewManager from '@/components/shared/SavedViewManager';
@@ -107,9 +107,9 @@ function parseExtraFilters(searchParams: URLSearchParams): Partial<VideosQuery>
const v = searchParams.get(k);
if (v !== null) {
if (k === 'embeddable' || k === 'selfDeclaredMadeForKids') {
(result as any)[k] = v === 'true';
(result as Record<string, string | boolean>)[k] = v === 'true';
} else {
(result as any)[k] = v;
(result as Record<string, string | boolean>)[k] = v;
}
}
}
@@ -133,7 +133,7 @@ export default function VideosPage() {
const { visible, order, setColumns } = useColumnPreferences('videos');
const columnVisibility = useMemo<ColumnVisibilityState>(() => {
const columnVisibility = useMemo<VisibilityState>(() => {
const allIds = VIDEO_COLUMN_DEFS.map((c) => c.id);
return Object.fromEntries(allIds.map((id) => [id, visible.includes(id)]));
}, [visible]);
@@ -193,14 +193,6 @@ export default function VideosPage() {
setPage(1);
}, [searchParams, router]);
const clearExtraFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
for (const k of EXTRA_FILTER_KEYS) params.delete(k);
params.delete('pendingSync'); params.delete('remoteConflict');
params.delete('lintStatus'); params.delete('privacyStatus'); params.delete('search');
router.replace(`/videos?${params.toString()}`);
}, [searchParams, router]);
// Build combined filters for API (base tab query + extra overlays)
const urlSearch = searchParams.get('search') ?? undefined;
const urlLintStatus = searchParams.get('lintStatus') ?? undefined;
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Pin, PinOff, Trash2, ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
import {
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
type SavedView, type VideosQuery, type UserPreferences,
type SavedView, type VideosQuery,
} from '@/lib/api';
import styles from './SavedViewManager.module.css';
@@ -8,7 +8,8 @@ import {
createColumnHelper,
type SortingState,
type OnChangeFn,
type ColumnVisibilityState,
type VisibilityState,
type Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import {
@@ -522,8 +523,8 @@ export default function VideoTable({
data: VideoRow[];
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
columnVisibility?: ColumnVisibilityState;
onColumnVisibilityChange?: (v: ColumnVisibilityState) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (v: VisibilityState) => void;
columnOrder?: string[];
filters?: Partial<VideosQuery>;
onFiltersChange?: (patch: Partial<VideosQuery>) => void;
@@ -542,7 +543,10 @@ export default function VideoTable({
columnOrder: columnOrder ?? [],
},
onSortingChange,
onColumnVisibilityChange: onColumnVisibilityChange as any,
onColumnVisibilityChange: ((updater: Updater<VisibilityState>) => {
const next = typeof updater === 'function' ? updater(columnVisibility ?? {}) : updater;
onColumnVisibilityChange?.(next);
}) as OnChangeFn<VisibilityState>,
onColumnOrderChange: undefined,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
@@ -561,7 +565,7 @@ export default function VideoTable({
const clearFilters = () => {
const clear: Partial<VideosQuery> = {};
ACTIVE_FILTER_KEYS.forEach((k) => { (clear as any)[k] = undefined; });
ACTIVE_FILTER_KEYS.forEach((k) => { (clear as Record<string, undefined>)[k] = undefined; });
onFiltersChange?.(clear);
};
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchPreferences, patchPreferences } from '@/lib/api';
import { fetchPreferences, patchPreferences, type UserPreferences } from '@/lib/api';
import { useCallback } from 'react';
export type TableKey = 'videos' | 'linting';
@@ -37,7 +37,7 @@ export function useColumnPreferences(tableKey: TableKey) {
[tableKey]: { visible: nextVisible, order: nextOrder },
},
};
qc.setQueryData(['preferences'], (old: any) => ({ ...old, ...patch }));
qc.setQueryData(['preferences'], (old: UserPreferences | undefined) => ({ ...old, ...patch }));
await patchPreferences(patch);
},
[prefs, tableKey, qc],
+2
View File
@@ -630,6 +630,8 @@ export interface UserPreferences {
systemOrder?: string[];
hiddenSystem?: string[];
};
videoEditLeftCol?: string[];
videoEditRightCol?: string[];
}
export const fetchSavedViews = () =>
+6
View File
@@ -2,6 +2,12 @@
# The public domain Traefik routes to this app (no https://, no trailing slash).
DOMAIN=yourdomain.com
# ─── Image tag ────────────────────────────────────────────────────────────────
# Which tag to pull from git.devils.zone/devil/youtube-studio-flow-{backend,frontend}.
# Pin to a git short SHA or release tag for reproducible deploys; `latest` tracks
# whatever scripts/build-and-push.sh last pushed.
IMAGE_TAG=latest
# ─── Postgres ─────────────────────────────────────────────────────────────────
POSTGRES_USER=studioflow
POSTGRES_PASSWORD=change_me_strong_password
+16 -4
View File
@@ -10,11 +10,23 @@ Dieses Verzeichnis enthält die Konfiguration für die lokale Entwicklung und da
- **postgres:** PostgreSQL 16 (Port 5432)
- **redis:** Redis 7 für Caching und Queues (Port 6379)
## Schnellstart
## Schnellstart (Server-Deployment)
Images werden **nicht** auf dem Server gebaut — sie kommen fertig aus der Gitea Container
Registry (`git.devils.zone/devil/youtube-studio-flow-{backend,frontend}`), gebaut per
`scripts/build-and-push.sh` auf der Dev-Maschine.
1. `.env.example` kopieren: `cp .env.example .env`
2. Werte in `.env` anpassen (besonders Google API Keys).
3. Container starten: `docker compose up -d`
4. Datenbank migrieren: `docker compose run --rm migrate`
2. Werte in `.env` anpassen (Secrets, Domain, Google API Keys, `IMAGE_TAG`).
3. Am Server einloggen: `docker login git.devils.zone`
4. Images ziehen: `docker compose pull`
5. Datenbank migrieren: `docker compose run --rm migrate`
6. Container starten: `docker compose up -d`
Für ein Update auf eine neue Version: `IMAGE_TAG` in `.env` anpassen (oder `latest`
belassen), dann Schritte 46 wiederholen. Das externe Docker-Netzwerk `proxy`
muss vorher existieren (`docker network create proxy`), falls Traefik das nicht
bereits selbst anlegt.
## Daten-Persistenz
- PostgreSQL-Daten liegen im Volume `postgres_data`.
+21 -21
View File
@@ -2,9 +2,8 @@ services:
# Runs database migrations once before the API and worker start.
migrate:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
pull_policy: always
command: npx prisma migrate deploy
restart: "no"
environment:
@@ -16,9 +15,8 @@ services:
- app-network
api:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
environment:
NODE_ENV: production
@@ -48,18 +46,17 @@ services:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health || exit 1"]
interval: 15s
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
retries: 10
start_period: 60s
networks:
- app-network
- traefik-network
- proxy
worker:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
command: node dist/worker.js
environment:
@@ -84,11 +81,11 @@ services:
- app-network
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
# NEXT_PUBLIC_API_URL defaults to /api/v1 in the Dockerfile — no override needed.
# The relative path works because Traefik serves both frontend and API on the same domain.
# NEXT_PUBLIC_API_URL is baked in at build time (scripts/build-and-push.sh uses the
# Dockerfile default, /api/v1) — the relative path works because Traefik serves both
# frontend and API on the same domain.
image: git.devils.zone/devil/youtube-studio-flow-frontend:${IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
labels:
- "traefik.enable=true"
@@ -98,11 +95,14 @@ services:
- "traefik.http.routers.studioflow-frontend.priority=1"
- "traefik.http.services.studioflow-frontend.loadbalancer.server.port=3000"
depends_on:
# service_started, not service_healthy: a slow/unhealthy api shouldn't block the
# whole `compose up` (and Portainer tearing down what it created) - the frontend
# itself doesn't need api to be ready at container-start time.
api:
condition: service_healthy
condition: service_started
networks:
- app-network
- traefik-network
- proxy
postgres:
image: postgres:16-alpine
@@ -149,5 +149,5 @@ volumes:
networks:
app-network:
driver: bridge
traefik-network:
proxy:
external: true
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Builds the backend and frontend images and pushes them to the Gitea
# container registry at git.devils.zone. Requires `docker login
# git.devils.zone` to already be done (run that yourself, out of band, so
# the token never ends up in a Claude transcript).
#
# Usage: scripts/build-and-push.sh [tag]
# tag Extra tag to push alongside the git short SHA and `latest`.
# Useful for a release, e.g. scripts/build-and-push.sh v0.1.0
set -euo pipefail
REGISTRY="git.devils.zone"
OWNER="devil"
SHA=$(git rev-parse --short HEAD)
EXTRA_TAG="${1:-}"
BACKEND_IMAGE="${REGISTRY}/${OWNER}/youtube-studio-flow-backend"
FRONTEND_IMAGE="${REGISTRY}/${OWNER}/youtube-studio-flow-frontend"
echo "==> Building backend (${BACKEND_IMAGE}:${SHA})"
docker build -t "${BACKEND_IMAGE}:${SHA}" -t "${BACKEND_IMAGE}:latest" ./backend
echo "==> Building frontend (${FRONTEND_IMAGE}:${SHA})"
docker build -t "${FRONTEND_IMAGE}:${SHA}" -t "${FRONTEND_IMAGE}:latest" ./frontend
if [ -n "$EXTRA_TAG" ]; then
docker tag "${BACKEND_IMAGE}:${SHA}" "${BACKEND_IMAGE}:${EXTRA_TAG}"
docker tag "${FRONTEND_IMAGE}:${SHA}" "${FRONTEND_IMAGE}:${EXTRA_TAG}"
fi
echo "==> Pushing backend"
docker push "${BACKEND_IMAGE}:${SHA}"
docker push "${BACKEND_IMAGE}:latest"
[ -n "$EXTRA_TAG" ] && docker push "${BACKEND_IMAGE}:${EXTRA_TAG}"
echo "==> Pushing frontend"
docker push "${FRONTEND_IMAGE}:${SHA}"
docker push "${FRONTEND_IMAGE}:latest"
[ -n "$EXTRA_TAG" ] && docker push "${FRONTEND_IMAGE}:${EXTRA_TAG}"
echo "==> Done. Pushed tags: ${SHA}, latest${EXTRA_TAG:+, $EXTRA_TAG}"
echo " On the server: docker compose pull && docker compose run --rm migrate && docker compose up -d"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# One-time migration: dump the local dev Postgres and restore it into the
# server's Postgres over SSH, replacing whatever is there.
#
# DESTRUCTIVE on the target: drops and recreates the remote database.
#
# Fill in the placeholders below (or export them as env vars before running),
# then run from the project root: bash scripts/migrate-local-db-to-server.sh
#
# Prerequisites:
# - Local Postgres running: cd infrastructure && docker compose up -d postgres redis
# - SSH access to the server
# - The remote postgres container's name (find it with `docker ps` on the
# server, or check the container list in Portainer for your stack - it'll
# be something like <stack-name>-postgres-1)
set -euo pipefail
# Git Bash/MSYS auto-translates POSIX-looking path args (e.g. /tmp/foo) into
# Windows paths before handing them to docker.exe - but these paths are meant
# to be interpreted inside the Linux container, not on the Windows host.
export MSYS_NO_PATHCONV=1
# ── Fill these in ──────────────────────────────────────────────────────────
SSH_USER="${SSH_USER:-dummyuser}"
SSH_HOST="${SSH_HOST:-dummy.server.example}"
SSH_PORT="${SSH_PORT:-22}"
REMOTE_POSTGRES_CONTAINER="${REMOTE_POSTGRES_CONTAINER:-studioflow-postgres-1}"
REMOTE_POSTGRES_USER="${REMOTE_POSTGRES_USER:-studioflow}"
REMOTE_POSTGRES_DB="${REMOTE_POSTGRES_DB:-studioflow}"
# ────────────────────────────────────────────────────────────────────────────
LOCAL_DB_USER="studioflow"
LOCAL_DB_NAME="studioflow"
DUMP_FILE="studioflow_migration_$(date +%Y%m%d_%H%M%S).dump"
echo "==> Finding local Postgres container"
LOCAL_PG_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i postgres | head -1)
if [ -z "$LOCAL_PG_CONTAINER" ]; then
echo "No running local postgres container found. Start it first:"
echo " cd infrastructure && docker compose up -d postgres redis"
exit 1
fi
echo " Using: $LOCAL_PG_CONTAINER"
echo "==> Dumping local database ($LOCAL_DB_NAME)"
docker exec "$LOCAL_PG_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" -F c -f "/tmp/$DUMP_FILE"
docker cp "${LOCAL_PG_CONTAINER}:/tmp/$DUMP_FILE" "./$DUMP_FILE"
docker exec "$LOCAL_PG_CONTAINER" rm "/tmp/$DUMP_FILE"
echo " Dump size: $(du -h "./$DUMP_FILE" | cut -f1)"
echo ""
echo "About to overwrite the database on ${SSH_HOST} (container: ${REMOTE_POSTGRES_CONTAINER})."
read -p "Type YES to continue: " CONFIRM
if [ "$CONFIRM" != "YES" ]; then
echo "Aborted. Local dump kept at ./$DUMP_FILE"
exit 1
fi
echo "==> Copying dump to server"
scp -P "$SSH_PORT" "./$DUMP_FILE" "${SSH_USER}@${SSH_HOST}:/tmp/$DUMP_FILE"
echo "==> Restoring on server"
ssh -p "$SSH_PORT" "${SSH_USER}@${SSH_HOST}" bash -s <<EOF
set -euo pipefail
echo " Copying dump into container..."
docker cp "/tmp/$DUMP_FILE" "${REMOTE_POSTGRES_CONTAINER}:/tmp/$DUMP_FILE"
echo " Terminating existing connections..."
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$REMOTE_POSTGRES_DB';" >/dev/null
echo " Dropping and recreating database..."
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
"DROP DATABASE IF EXISTS $REMOTE_POSTGRES_DB;" >/dev/null
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
"CREATE DATABASE $REMOTE_POSTGRES_DB OWNER $REMOTE_POSTGRES_USER;" >/dev/null
echo " Restoring data..."
docker exec "$REMOTE_POSTGRES_CONTAINER" pg_restore -U "$REMOTE_POSTGRES_USER" -d "$REMOTE_POSTGRES_DB" --no-owner --no-privileges "/tmp/$DUMP_FILE"
docker exec "$REMOTE_POSTGRES_CONTAINER" rm "/tmp/$DUMP_FILE"
rm "/tmp/$DUMP_FILE"
echo " Done."
EOF
echo ""
echo "==> Migration complete."
echo " Restart api/worker in Portainer (or they'll self-heal on next DB query)."
echo " Local dump kept at ./$DUMP_FILE - delete it once you've confirmed the server looks right."