Merge branch 'main' of https://github.com/classroomio/classroomio
This commit is contained in:
+21
-14
@@ -3,6 +3,15 @@
|
||||
# For local development, the defaults work out of the box.
|
||||
# For production, replace all placeholder values.
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Image version (only used by docker-compose.images.yaml — the pull-based stack)
|
||||
# ──────────────────────────────────────────────
|
||||
# Which published image tag to pull for api/dashboard/jobs.
|
||||
# - Pin to a release (e.g. 1.4.2) in production so a new release never auto-breaks you.
|
||||
# - "latest" = newest stable release. "edge" = bleeding edge (last main build).
|
||||
# Rollback = set this back to the previous version, then `... pull && up -d`.
|
||||
CIO_VERSION=latest
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Required
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -12,17 +21,17 @@ POSTGRES_DB=classroomio
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# Auth secret for session signing (use a long random string in production)
|
||||
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
|
||||
# Auth secret for session signing. REQUIRED — the stack refuses to start if this is empty.
|
||||
BETTER_AUTH_SECRET=
|
||||
|
||||
# API bearer token (leave blank — auto-generated by ./run-docker-full-stack.sh)
|
||||
AUTH_BEARER_TOKEN=
|
||||
|
||||
# Dashboard-to-API auth key (leave blank — auto-generated by ./run-docker-full-stack.sh)
|
||||
# Dashboard-to-API auth key (leave blank — auto-generated by ./run-docker-full-stack.sh).
|
||||
# Must be the same value in both API and dashboard.
|
||||
PRIVATE_SERVER_KEY=
|
||||
|
||||
# Self-hosted mode flag
|
||||
# Self-hosted mode flag.
|
||||
# Note: for the dashboard this is a BUILD-TIME value (baked into the client bundle via a
|
||||
# Docker build arg). The published dashboard image is built with PUBLIC_IS_SELFHOSTED=true;
|
||||
# changing it at runtime has no effect on an already-built image.
|
||||
PUBLIC_IS_SELFHOSTED=true
|
||||
|
||||
# Dashboard SSR calls to the API inside the Docker network
|
||||
@@ -60,13 +69,10 @@ ALLOWED_EXTERNAL_DOMAINS=
|
||||
# CSP_MEDIA_SRC_DOMAINS= (applies to both img-src and media-src)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Optional — Email (pick one approach, or skip)
|
||||
# Optional — Email (SMTP, or skip)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Option A: Zoho / ZeptoMail
|
||||
ZOHO_TOKEN=
|
||||
|
||||
# Option B: SMTP
|
||||
# SMTP
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
@@ -77,8 +83,9 @@ SMTP_SENDER=
|
||||
# Optional — Object Storage (auto-configured by ./run-docker-full-stack.sh)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# MinIO is included by default. The startup script sets these automatically.
|
||||
# Change credentials in production.
|
||||
# MinIO is included by default. On first run ./run-docker-full-stack.sh RANDOMIZES the
|
||||
# MinIO credentials below and mirrors them into OBJECT_STORAGE_ACCESS_KEY_ID/SECRET.
|
||||
# These minioadmin placeholders are only used if you skip the script.
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
OBJECT_STORAGE_ENDPOINT=http://minio:9000
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Files executed inside Linux containers must use LF; CRLF breaks script
|
||||
# shebangs at container start (e.g. /app/entrypoint.sh) regardless of core.autocrlf.
|
||||
*.sh text eol=lf
|
||||
Dockerfile* text eol=lf
|
||||
@@ -4,6 +4,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Only rebuild images when something that affects them changes (not docs-only
|
||||
# commits). Tag pushes and manual dispatch always run.
|
||||
paths:
|
||||
- "apps/api/**"
|
||||
- "apps/dashboard/**"
|
||||
- "apps/jobs/**"
|
||||
- "packages/**"
|
||||
- "docker/**"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
@@ -13,11 +23,71 @@ on:
|
||||
required: false
|
||||
default: "latest"
|
||||
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'classroomio' }}
|
||||
|
||||
jobs:
|
||||
# Boot each freshly built image against ephemeral postgres + redis and confirm it
|
||||
# actually starts BEFORE we publish anything. A broken image never reaches users.
|
||||
smoke:
|
||||
name: Smoke test (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- name: api
|
||||
dockerfile: ./docker/Dockerfile.api
|
||||
image: api
|
||||
- name: dashboard
|
||||
dockerfile: ./docker/Dockerfile.dashboard
|
||||
image: dashboard
|
||||
- name: jobs
|
||||
dockerfile: ./docker/Dockerfile.jobs
|
||||
image: jobs
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: classroomio
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 15
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build amd64 image (load locally)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
load: true
|
||||
tags: cio-smoke-${{ matrix.image }}:test
|
||||
platforms: linux/amd64
|
||||
cache-from: type=registry,ref=${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}:buildcache
|
||||
|
||||
- name: Boot smoke test
|
||||
run: bash docker/smoke-test.sh ${{ matrix.name }} cio-smoke-${{ matrix.image }}:test
|
||||
|
||||
build-and-push:
|
||||
needs: smoke
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -28,11 +98,18 @@ jobs:
|
||||
- name: dashboard
|
||||
dockerfile: ./docker/Dockerfile.dashboard
|
||||
image: dashboard
|
||||
- name: jobs
|
||||
dockerfile: ./docker/Dockerfile.jobs
|
||||
image: jobs
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
# Required to build linux/arm64 on an amd64 runner.
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -47,9 +124,15 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}
|
||||
# Tag policy (stable-releases + edge):
|
||||
# git tag v1.4.2 -> 1.4.2, 1.4, 1, latest (a real, stable release)
|
||||
# push to main -> edge only (latest is NEVER moved by a merge)
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=raw,value=edge,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -61,7 +144,7 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.DOCKERHUB_USERNAME }}/${{ matrix.image }}:buildcache,mode=max
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
@@ -33,18 +33,28 @@ jobs:
|
||||
ls -la && echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Setup turbo cache
|
||||
# Turbo cache keyed by commit SHA, with a prefix restore-key so a previous run of
|
||||
# this workflow warms the @cio/api build instead of a cold rebuild.
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-turbo-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build @cio/api workspace dependencies
|
||||
run: pnpm turbo run build --filter=@cio/api^...
|
||||
run: pnpm turbo run build --filter=@cio/api^... --cache-dir=.turbo
|
||||
|
||||
- name: Upload to Cloudflare R2
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
+3
-4
@@ -43,7 +43,6 @@ REDIS_URL="redis://localhost:6379"
|
||||
PUBLIC_SERVER_URL="http://localhost:3002"
|
||||
TRUSTED_ORIGINS="http://localhost:5173" # the dashboard dev origin
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
AUTH_BEARER_TOKEN="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # generate once; use the SAME value in the dashboard
|
||||
```
|
||||
|
||||
@@ -70,10 +69,10 @@ cp apps/api/.env apps/jobs/.env
|
||||
|
||||
### Step 3 — Start infrastructure (Postgres + Redis)
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yaml up -d postgres redis
|
||||
docker compose -f docker-compose.yaml up -d postgres redis
|
||||
```
|
||||
(Optional — object storage for uploads:
|
||||
`docker compose -f docker/docker-compose.yaml --profile minio up -d minio minio-init`.)
|
||||
`docker compose -f docker-compose.yaml --profile minio up -d minio minio-init`.)
|
||||
|
||||
### Step 4 — Set up + seed the database
|
||||
```bash
|
||||
@@ -150,7 +149,7 @@ Seeded accounts use password **`123456`** (verified against the seed's bcrypt ha
|
||||
|---------|-------|-----|
|
||||
| `no such service: db-init` | `db-init` was removed; the `api` container now runs DB setup | Don't reference `db-init`. For host dev use `db:setup:seed` (Step 4). |
|
||||
| `DATABASE_URL or PRIVATE_DATABASE_URL ... required` | `packages/db/.env` missing (db scripts read their own dir) | `cp packages/db/.env.example packages/db/.env` |
|
||||
| `password authentication failed for user "postgres"` | `localhost:5432` is another project's Postgres, not classroomio's | Stop the other container / free port 5432, then recreate: `docker compose -f docker/docker-compose.yaml up -d postgres` |
|
||||
| `password authentication failed for user "postgres"` | `localhost:5432` is another project's Postgres, not classroomio's | Stop the other container / free port 5432, then recreate: `docker compose -f docker-compose.yaml up -d postgres` |
|
||||
| `Failed to resolve entry for package "@cio/*"` | shared packages not built (`dist/` missing) | `pnpm build`, then re-run the dev commands |
|
||||
| `'rm'/'cp' is not recognized` or `spawn EINVAL` (Windows) | Unix command / `.cmd` spawn in a script | Already fixed in-repo (rimraf, Node copy, `shell:true`). Run `pnpm i` to get latest. |
|
||||
| `invalid task configuration … N persistent tasks … concurrency` | `pnpm dev` starts the whole monorepo past turbo's cap | Use scoped `pnpm api:dev` + `pnpm dashboard:dev` instead |
|
||||
|
||||
+2
-2
@@ -467,8 +467,8 @@ both the API/db layer (`process.env.PUBLIC_IS_SELFHOSTED`) and the dashboard
|
||||
|
||||
### 5.1 Self‑Hosted mode (`PUBLIC_IS_SELFHOSTED=true`)
|
||||
- **Setup:** Docker Compose full stack — `cp .env.example .env` then
|
||||
`./run-docker-full-stack.sh` (auto‑generates `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY`);
|
||||
`README.md:240–249`, `docker/docker-compose.yaml`, `docker/Dockerfile.{api,dashboard}`,
|
||||
`./run-docker-full-stack.sh` (auto‑generates `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET`);
|
||||
`README.md:240–249`, `docker-compose.yaml`, `docker/Dockerfile.{api,dashboard}`,
|
||||
`docker/docs/SELF_HOST.md`. Bundled Postgres, Redis, MinIO.
|
||||
- **Single organization:** org creation **blocks a second org**
|
||||
(`apps/api/src/services/onboarding.ts:28–33`); the new org is auto‑assigned an
|
||||
|
||||
@@ -148,7 +148,6 @@ The repository also contains shared packages under `packages/` (for example `pac
|
||||
PUBLIC_SERVER_URL="http://localhost:3002"
|
||||
TRUSTED_ORIGINS="http://localhost:5173" # the dashboard dev origin
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
AUTH_BEARER_TOKEN="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # generate once; use the SAME value in the dashboard
|
||||
DASHBOARD_ORIGIN="http://localhost:5173" # optional; used for invite/email links back to the dashboard
|
||||
```
|
||||
@@ -168,7 +167,7 @@ The repository also contains shared packages under `packages/` (for example `pac
|
||||
6. Start local infrastructure for API (Postgres + Redis) and seed the DB:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yaml up -d postgres redis
|
||||
docker compose -f docker-compose.yaml up -d postgres redis
|
||||
cp packages/db/.env.example packages/db/.env # one-time (Windows: Copy-Item)
|
||||
pnpm --filter @cio/db db:setup:seed
|
||||
```
|
||||
@@ -180,7 +179,7 @@ The repository also contains shared packages under `packages/` (for example `pac
|
||||
7. (Optional) Start MinIO locally for object storage (media/documents):
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yaml --profile minio up -d minio minio-init
|
||||
docker compose -f docker-compose.yaml --profile minio up -d minio minio-init
|
||||
```
|
||||
|
||||
- Console: http://localhost:9001 (user/pass default `minioadmin` / `minioadmin`)
|
||||
@@ -248,7 +247,7 @@ cp .env.example .env # copy env template, edit for your domain
|
||||
./run-docker-full-stack.sh
|
||||
```
|
||||
|
||||
The script reads root `.env` via `docker compose --env-file .env` and auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when missing.
|
||||
The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing.
|
||||
|
||||
See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide.
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ PUBLIC_SERVER_URL=""
|
||||
TRUSTED_ORIGINS=""
|
||||
BETTER_AUTH_SECRET=""
|
||||
|
||||
AUTH_BEARER_TOKEN=""
|
||||
AUTH_COOKIE_DOMAIN=""
|
||||
|
||||
# Email provider — used by the API only for auth-flow emails (forgot
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
FROM node:slim AS app
|
||||
|
||||
# Add build arguments
|
||||
ARG CLOUDFLARE_BUCKET_DOMAIN
|
||||
ARG CLOUDFLARE_ACCESS_KEY
|
||||
ARG CLOUDFLARE_SECRET_ACCESS_KEY
|
||||
ARG CLOUDFLARE_ACCOUNT_ID
|
||||
ARG CLOUDFLARE_BUCKET_ID
|
||||
ARG SMTP_HOST
|
||||
ARG SMTP_USER
|
||||
ARG SMTP_PASSWORD
|
||||
ARG SMTP_PORT
|
||||
ARG SMTP_SENDER
|
||||
ARG ZOHO_TOKEN
|
||||
ARG CLOUDFLARE_RENDERING_API_KEY
|
||||
ARG OPENAPI_URL
|
||||
|
||||
RUN mkdir /app
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm and healthcheck tools
|
||||
RUN npm install -g pnpm && \
|
||||
apt-get update && \
|
||||
apt-get install -y curl wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install chrome stable from sources, then remove it. Why? The
|
||||
# npm install of `puppeteer` brings its own bundle chromium build,
|
||||
# and puppeteer releases are only guaranteed to work with that version.
|
||||
#
|
||||
# But the bundled chromium implicitly needs a bunch of shared libs on
|
||||
# the host. It's a little tedious to find and maintain that list; but
|
||||
# the official apt distro of `google-chrome-stable` should bring the right
|
||||
# set along. So do that, but immediately uninstall (to free up layer space).
|
||||
#
|
||||
# This is a little brittle, since the puppeteer chrome could in theory diverge
|
||||
# from the official apt chrome's shared lib deps. But it works..
|
||||
# RUN apt-get update \
|
||||
# && apt-get install curl gnupg -y \
|
||||
# && curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
|
||||
# && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
|
||||
# && apt-get update \
|
||||
# && apt-get install google-chrome-stable -y --no-install-recommends \
|
||||
# && apt-get remove google-chrome-stable -y \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add emoji fonts.
|
||||
# Source: https://gist.github.com/win0err/9d8c7f0feabdfe8a4c9787b02c79ac51
|
||||
# RUN mkdir ~/.fonts/ && \
|
||||
# wget https://github.com/samuelngs/apple-emoji-linux/releases/download/ios-15.4/AppleColorEmoji.ttf -O ~/.fonts/AppleColorEmoji.ttf
|
||||
|
||||
COPY ./package.json /app
|
||||
RUN pnpm install
|
||||
COPY . /app
|
||||
|
||||
# Set environment variables from build args
|
||||
ENV NODE_ENV=production \
|
||||
CLOUDFLARE_BUCKET_DOMAIN=$CLOUDFLARE_BUCKET_DOMAIN \
|
||||
CLOUDFLARE_ACCESS_KEY=$CLOUDFLARE_ACCESS_KEY \
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=$CLOUDFLARE_SECRET_ACCESS_KEY \
|
||||
CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID \
|
||||
CLOUDFLARE_BUCKET_ID=$CLOUDFLARE_BUCKET_ID \
|
||||
CLOUDFLARE_RENDERING_API_KEY=$CLOUDFLARE_RENDERING_API_KEY \
|
||||
SMTP_HOST=$SMTP_HOST \
|
||||
SMTP_USER=$SMTP_USER \
|
||||
SMTP_PASSWORD=$SMTP_PASSWORD \
|
||||
SMTP_PORT=$SMTP_PORT \
|
||||
SMTP_SENDER=$SMTP_SENDER \
|
||||
ZOHO_TOKEN=$ZOHO_TOKEN \
|
||||
OPENAPI_URL=$OPENAPI_URL \
|
||||
PORT=3002
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3002 || exit 1
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -1,16 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -34,43 +26,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
redirect(`/courses/${id}/settings#share`);
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
redirect(`/courses/${id}/people?add=true`);
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
function handleCloneCourse() {
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenPublicCourse() {
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lmsPublicQuickOnly ? showPublicCourseLinks : true}
|
||||
@@ -84,41 +39,7 @@
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={handleOpenPublicCourse}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<CourseContextMenuContent {id} {title} {description} {isPublished} {courseType} {slug} {lmsPublicQuickOnly} />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
courseType?: string | null;
|
||||
slug?: string;
|
||||
/** Compact menu for LMS course cards */
|
||||
lmsPublicQuickOnly?: boolean;
|
||||
/** Include "View as student" (course header menu) */
|
||||
includeViewAsStudent?: boolean;
|
||||
onViewAsStudent?: () => void;
|
||||
/** List view includes an explicit Open action */
|
||||
includeOpen?: boolean;
|
||||
/** Hide org management actions (clone, share, invite, delete) */
|
||||
hideOrgActions?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
isPublished = false,
|
||||
courseType = null,
|
||||
slug = '',
|
||||
lmsPublicQuickOnly = false,
|
||||
includeViewAsStudent = false,
|
||||
onViewAsStudent,
|
||||
includeOpen = false,
|
||||
hideOrgActions = false
|
||||
}: Props = $props();
|
||||
|
||||
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
}
|
||||
|
||||
function handleShareCourse() {
|
||||
redirect(`/courses/${id}/settings#share`);
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
redirect(`/courses/${id}/people?add=true`);
|
||||
}
|
||||
|
||||
function handlePublishCourse() {
|
||||
redirect(`/courses/${id}/settings#publish`);
|
||||
}
|
||||
|
||||
function handleDeleteCourse() {
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
function handleCloneCourse() {
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourse() {
|
||||
redirect(`/courses/${id}`);
|
||||
}
|
||||
|
||||
function handleViewCourseSite() {
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lmsPublicQuickOnly}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#if includeViewAsStudent}
|
||||
<DropdownMenu.Item onclick={() => onViewAsStudent?.()}>
|
||||
<EyeIcon size={16} class="mr-2" />
|
||||
{$t('course.header.view_as_student')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
|
||||
{#if isPublished}
|
||||
<DropdownMenu.Item onclick={handleViewCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.view_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if !hideOrgActions || includeOpen}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
{:else}
|
||||
<DropdownMenu.Item onclick={handlePublishCourse}>
|
||||
{$t('courses.course_card.context_menu.publish_course')}
|
||||
</DropdownMenu.Item>
|
||||
{#if !hideOrgActions || includeOpen}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if includeOpen}
|
||||
<DropdownMenu.Item onclick={handleOpenCourse}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if !hideOrgActions}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleShareCourse}>
|
||||
{$t('courses.course_card.context_menu.share')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleInvite}>
|
||||
{$t('courses.course_card.context_menu.invite')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import * as ButtonGroup from '@cio/ui/base/button-group';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Waves } from '@cio/ui/custom/animation';
|
||||
import { page } from '$app/state';
|
||||
@@ -12,23 +15,46 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { getActiveCourseNavKey } from '$features/course/utils/functions';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
import ViewAsStudentModal from './view-as-student-modal.svelte';
|
||||
import ViewCourseSiteUnpublishedModal from './view-course-site-unpublished-modal.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
const showCoursePublishBadge = $derived(!$isStudentExperience);
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? ''));
|
||||
const isPublished = $derived(courseApi.course?.isPublished ?? false);
|
||||
|
||||
let viewAsStudentOpen = $state(false);
|
||||
let viewCourseSiteUnpublishedOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!siteName) return;
|
||||
|
||||
setupProgressApi.fetchSetupProgress(siteName);
|
||||
});
|
||||
|
||||
function handleViewCourseSite() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPublished) {
|
||||
viewCourseSiteUnpublishedOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openCoursePreview({
|
||||
courseId: course.id,
|
||||
courseSlug: course.slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -52,7 +78,7 @@
|
||||
{/if}
|
||||
|
||||
{#if showCoursePublishBadge}
|
||||
<CoursePublishBadge isPublished={courseApi.course?.isPublished ?? false} />
|
||||
<CoursePublishBadge {isPublished} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,16 +104,47 @@
|
||||
</Button>
|
||||
|
||||
{#if !$isStudentExperience}
|
||||
<ButtonGroup.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (viewAsStudentOpen = true)}
|
||||
onclick={handleViewCourseSite}
|
||||
disabled={!courseApi.course?.id}
|
||||
aria-label={$t('course.header.view_as_student')}
|
||||
aria-label={$t('course.header.view_course_site')}
|
||||
>
|
||||
<EyeIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_as_student')}</span>
|
||||
<ExternalLinkIcon size={14} />
|
||||
<span class="hidden sm:inline">{$t('course.header.view_course_site')}</span>
|
||||
</Button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={$t('courses.course_card.actions_menu_aria')}
|
||||
disabled={!courseApi.course?.id}
|
||||
>
|
||||
<EllipsisVerticalIcon size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if courseApi.course}
|
||||
<CourseContextMenuContent
|
||||
id={courseApi.course.id}
|
||||
title={courseApi.course.title}
|
||||
description={courseApi.course.description}
|
||||
isPublished={courseApi.course.isPublished ?? false}
|
||||
courseType={courseApi.course.type}
|
||||
slug={courseApi.course.slug ?? ''}
|
||||
includeViewAsStudent={true}
|
||||
onViewAsStudent={() => (viewAsStudentOpen = true)}
|
||||
/>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ButtonGroup.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -98,3 +155,5 @@
|
||||
courseSlug={courseApi.course?.slug}
|
||||
currentOrgDomain={$currentOrgDomain}
|
||||
/>
|
||||
|
||||
<ViewCourseSiteUnpublishedModal bind:open={viewCourseSiteUnpublishedOpen} currentOrgDomain={$currentOrgDomain} />
|
||||
|
||||
@@ -10,14 +10,10 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { Image } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { buildCoursePlaceholderAvatarUrl } from '$features/course/utils/course-list-row-utils';
|
||||
import CourseContentIcon from './course-content-icon.svelte';
|
||||
import CoursePublicBadge from './course-public-badge.svelte';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import CourseContextMenuContent from './course-context-menu-content.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
@@ -133,30 +129,6 @@
|
||||
}
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleOpen(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
goto(resolve(`/courses/${id}`, {}));
|
||||
}
|
||||
|
||||
function handleClone(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$copyCourseModal.open = true;
|
||||
$copyCourseModal.id = id;
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
$deleteCourseModal.open = true;
|
||||
$deleteCourseModal.id = id;
|
||||
$deleteCourseModal.title = title;
|
||||
}
|
||||
|
||||
async function handleCopyCourseUrl() {
|
||||
await copyPublicCoursePageUrl(slug, $currentOrgDomain);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
@@ -317,37 +289,27 @@
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Item
|
||||
onclick={() =>
|
||||
openCoursePreview({
|
||||
courseId: id,
|
||||
courseSlug: slug,
|
||||
currentOrgDomain: $currentOrgDomain
|
||||
})}
|
||||
>
|
||||
<ExternalLinkIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.open_public_course')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||
<CopyIcon class="mr-2 size-4" />
|
||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if !isLMS}
|
||||
{#if showPublicCourseLinks}
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleOpen}>
|
||||
{$t('courses.course_card.context_menu.open')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleClone}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||
{$t('courses.course_card.context_menu.delete')}
|
||||
</DropdownMenu.Item>
|
||||
{#if isLMS}
|
||||
<CourseContextMenuContent
|
||||
{id}
|
||||
{title}
|
||||
{description}
|
||||
{isPublished}
|
||||
courseType={type}
|
||||
{slug}
|
||||
lmsPublicQuickOnly={true}
|
||||
/>
|
||||
{:else}
|
||||
<CourseContextMenuContent
|
||||
{id}
|
||||
{title}
|
||||
{description}
|
||||
{isPublished}
|
||||
courseType={type}
|
||||
{slug}
|
||||
includeOpen={true}
|
||||
hideOrgActions={false}
|
||||
/>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { publishCourse } from '$features/course/utils/publish-course';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
currentOrgDomain?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), currentOrgDomain = '' }: Props = $props();
|
||||
|
||||
let isPublishing = $state(false);
|
||||
|
||||
async function handlePublishCourse() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
isPublishing = true;
|
||||
|
||||
const published = await publishCourse(course);
|
||||
isPublishing = false;
|
||||
|
||||
if (!published) {
|
||||
return;
|
||||
}
|
||||
|
||||
open = false;
|
||||
|
||||
openCoursePreview({
|
||||
courseId: course.id,
|
||||
courseSlug: courseApi.course?.slug,
|
||||
currentOrgDomain
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen && !isPublishing) {
|
||||
open = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-[calc(100%-2rem)] max-w-md! p-4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.view_course_site.unpublished_title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.view_course_site.unpublished_description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (open = false)} disabled={isPublishing}>
|
||||
{$t('course.view_course_site.cancel')}
|
||||
</Button>
|
||||
<Button variant="default" onclick={handlePublishCourse} loading={isPublishing} disabled={!courseApi.course?.id}>
|
||||
{$t('course.view_course_site.publish_course')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -794,7 +794,7 @@
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Set id="publish">
|
||||
<Field.Legend>{$t('course.navItem.settings.publish')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.determines')}</Field.Description>
|
||||
<Field.Field orientation="horizontal">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { isObject } from '$lib/utils/functions/isObject';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import type { Course } from '$features/course/utils/types';
|
||||
|
||||
export async function publishCourse(course: Course): Promise<boolean> {
|
||||
if (!course?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let slug = course.slug?.trim() ? course.slug : undefined;
|
||||
if (!slug) {
|
||||
slug = generateSlug(course.title, { appendTimestamp: true });
|
||||
}
|
||||
|
||||
const metadataPayload = {
|
||||
...(isObject(course.metadata) ? course.metadata : {}),
|
||||
allowNewStudent: true
|
||||
};
|
||||
|
||||
const result = await courseApi.update(
|
||||
course.id,
|
||||
{
|
||||
isPublished: true,
|
||||
slug,
|
||||
metadata: metadataPayload
|
||||
},
|
||||
{ showSuccessToast: true }
|
||||
);
|
||||
|
||||
return !!result;
|
||||
}
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Se som studerende"
|
||||
"view_as_student": "Se som studerende",
|
||||
"view_course_site": "Se kursusside"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Udgiv dit kursus",
|
||||
"unpublished_description": "Du bør udgive dette kursus, før du kan se sitet",
|
||||
"cancel": "Annuller",
|
||||
"publish_course": "Udgiv kursus"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Tilbage til kurset",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Slet",
|
||||
"open": "Åbn",
|
||||
"open_public_course": "Åbn offentligt kursus",
|
||||
"view_course_site": "Se kursusside",
|
||||
"publish_course": "Udgiv kursus",
|
||||
"copy_course_url": "Kopiér kursus-URL"
|
||||
},
|
||||
"due_date": "Forfalder",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Als Student anzeigen"
|
||||
"view_as_student": "Als Student anzeigen",
|
||||
"view_course_site": "Kursseite anzeigen"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Kurs veröffentlichen",
|
||||
"unpublished_description": "Sie sollten diesen Kurs veröffentlichen, bevor Sie die Seite ansehen können",
|
||||
"cancel": "Abbrechen",
|
||||
"publish_course": "Kurs veröffentlichen"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Zurück zum Kurs",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Löschen",
|
||||
"open": "Offen",
|
||||
"open_public_course": "Öffentlichen Kurs öffnen",
|
||||
"view_course_site": "Kursseite anzeigen",
|
||||
"publish_course": "Kurs veröffentlichen",
|
||||
"copy_course_url": "Kurs-URL kopieren"
|
||||
},
|
||||
"due_date": "Fällig",
|
||||
|
||||
@@ -1970,6 +1970,8 @@
|
||||
"invite": "Invite",
|
||||
"delete": "Delete",
|
||||
"open_public_course": "Open public course",
|
||||
"view_course_site": "View course site",
|
||||
"publish_course": "Publish course",
|
||||
"copy_course_url": "Copy course URL"
|
||||
},
|
||||
"error_message": "An error occurred.",
|
||||
@@ -2147,7 +2149,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "View as student"
|
||||
"view_as_student": "View as student",
|
||||
"view_course_site": "View Course Site"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publish your course",
|
||||
"unpublished_description": "You should publish this course before you can see the site",
|
||||
"cancel": "Cancel",
|
||||
"publish_course": "Publish Course"
|
||||
},
|
||||
"view_as_student": {
|
||||
"title": "This will redirect you to the Student LMS.",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como estudiante"
|
||||
"view_as_student": "Ver como estudiante",
|
||||
"view_course_site": "Ver sitio del curso"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publica tu curso",
|
||||
"unpublished_description": "Debes publicar este curso antes de poder ver el sitio",
|
||||
"cancel": "Cancelar",
|
||||
"publish_course": "Publicar curso"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "volver al curso",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Eliminar",
|
||||
"open": "Abierto",
|
||||
"open_public_course": "Abrir curso público",
|
||||
"view_course_site": "Ver sitio del curso",
|
||||
"publish_course": "Publicar curso",
|
||||
"copy_course_url": "Copiar URL del curso"
|
||||
},
|
||||
"due_date": "debido",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Afficher en tant qu'étudiant"
|
||||
"view_as_student": "Afficher en tant qu'étudiant",
|
||||
"view_course_site": "Voir le site du cours"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publiez votre cours",
|
||||
"unpublished_description": "Vous devez publier ce cours avant de pouvoir voir le site",
|
||||
"cancel": "Annuler",
|
||||
"publish_course": "Publier le cours"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Retour au cours",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Supprimer",
|
||||
"open": "Ouvert",
|
||||
"open_public_course": "Ouvrir le cours public",
|
||||
"view_course_site": "Voir le site du cours",
|
||||
"publish_course": "Publier le cours",
|
||||
"copy_course_url": "Copier l’URL du cours"
|
||||
},
|
||||
"due_date": "Due",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "विद्यार्थी के रूप में देखें"
|
||||
"view_as_student": "विद्यार्थी के रूप में देखें",
|
||||
"view_course_site": "कोर्स साइट देखें"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "अपना कोर्स प्रकाशित करें",
|
||||
"unpublished_description": "साइट देखने से पहले आपको इस कोर्स को प्रकाशित करना चाहिए",
|
||||
"cancel": "रद्द करें",
|
||||
"publish_course": "कोर्स प्रकाशित करें"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "हटाएँ",
|
||||
"open": "खुला",
|
||||
"open_public_course": "सार्वजनिक कोर्स खोलें",
|
||||
"view_course_site": "कोर्स साइट देखें",
|
||||
"publish_course": "कोर्स प्रकाशित करें",
|
||||
"copy_course_url": "कोर्स URL कॉपी करें"
|
||||
},
|
||||
"due_date": "देय",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Wyświetl jako student"
|
||||
"view_as_student": "Wyświetl jako student",
|
||||
"view_course_site": "Zobacz stronę kursu"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Opublikuj swój kurs",
|
||||
"unpublished_description": "Powinieneś opublikować ten kurs, zanim zobaczysz stronę",
|
||||
"cancel": "Anuluj",
|
||||
"publish_course": "Opublikuj kurs"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Powrót na kurs",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Usuń",
|
||||
"open": "Otwórz",
|
||||
"open_public_course": "Otwórz publiczny kurs",
|
||||
"view_course_site": "Zobacz stronę kursu",
|
||||
"publish_course": "Opublikuj kurs",
|
||||
"copy_course_url": "Kopiuj adres URL kursu"
|
||||
},
|
||||
"due_date": "Termin",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Ver como aluno"
|
||||
"view_as_student": "Ver como aluno",
|
||||
"view_course_site": "Ver site do curso"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Publique seu curso",
|
||||
"unpublished_description": "Você deve publicar este curso antes de poder ver o site",
|
||||
"cancel": "Cancelar",
|
||||
"publish_course": "Publicar curso"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "De volta ao curso",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Excluir",
|
||||
"open": "Abrir",
|
||||
"open_public_course": "Abrir curso público",
|
||||
"view_course_site": "Ver site do curso",
|
||||
"publish_course": "Publicar curso",
|
||||
"copy_course_url": "Copiar URL do curso"
|
||||
},
|
||||
"due_date": "Devido",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Посмотреть как студент"
|
||||
"view_as_student": "Посмотреть как студент",
|
||||
"view_course_site": "Посмотреть сайт курса"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Опубликуйте курс",
|
||||
"unpublished_description": "Перед просмотром сайта необходимо опубликовать этот курс",
|
||||
"cancel": "Отмена",
|
||||
"publish_course": "Опубликовать курс"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Вернуться к курсу",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Удалить",
|
||||
"open": "Открыть",
|
||||
"open_public_course": "Открыть публичный курс",
|
||||
"view_course_site": "Посмотреть сайт курса",
|
||||
"publish_course": "Опубликовать курс",
|
||||
"copy_course_url": "Копировать URL курса"
|
||||
},
|
||||
"due_date": "Срок погашения",
|
||||
|
||||
@@ -2234,7 +2234,14 @@
|
||||
"header": {
|
||||
"preview": "Preview course",
|
||||
"preview_missing_slug": "Generate and save a course slug before previewing.",
|
||||
"view_as_student": "Xem với tư cách sinh viên"
|
||||
"view_as_student": "Xem với tư cách sinh viên",
|
||||
"view_course_site": "Xem trang khóa học"
|
||||
},
|
||||
"view_course_site": {
|
||||
"unpublished_title": "Xuất bản khóa học của bạn",
|
||||
"unpublished_description": "Bạn nên xuất bản khóa học này trước khi có thể xem trang web",
|
||||
"cancel": "Hủy",
|
||||
"publish_course": "Xuất bản khóa học"
|
||||
},
|
||||
"sidebar": {
|
||||
"back_to_course": "Quay lại khóa học",
|
||||
@@ -2420,6 +2427,8 @@
|
||||
"delete": "Xóa",
|
||||
"open": "Mở",
|
||||
"open_public_course": "Mở khóa học công khai",
|
||||
"view_course_site": "Xem trang khóa học",
|
||||
"publish_course": "Xuất bản khóa học",
|
||||
"copy_course_url": "Sao chép URL khóa học"
|
||||
},
|
||||
"due_date": "Đến hạn",
|
||||
|
||||
@@ -3,7 +3,7 @@ title: Cloudflare Setup
|
||||
description: Guide to setting up Cloudflare for ClassroomIO.
|
||||
---
|
||||
|
||||
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/quickstart/self-hosting#option-b-minio-s3-compatible-on-premise) instead.
|
||||
In this guide, you will learn how to get your Cloudflare environment variables and configure R2 buckets. ClassroomIO supports Cloudflare R2 for video uploads, document uploads, and media (images and video thumbnails). For on-premise or data-residency deployments, you can use [MinIO](/docs/self-hosted/docker#optional--object-storage) instead.
|
||||
|
||||
## R2 buckets overview
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"roles-and-permissions",
|
||||
|
||||
"---Contributor guides---",
|
||||
"contributor-guides"
|
||||
"contributor-guides",
|
||||
|
||||
"---Self Hosting---",
|
||||
"self-hosted"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"signup": "Sign Up",
|
||||
"onboarding": "Onboarding",
|
||||
"self-hosting": "Self-hosting"
|
||||
"onboarding": "Onboarding"
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
---
|
||||
title: Self-hosting ClassroomIO
|
||||
description: Guide to self-hosting ClassroomIO on your own servers.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
This guide covers two deployment paths: **Docker** (recommended) and **Railway**.
|
||||
|
||||

|
||||
|
||||
## Docker (Recommended)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- `git` installed
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```zsh title="Terminal"
|
||||
git clone https://github.com/classroomio/classroomio.git
|
||||
cd classroomio
|
||||
```
|
||||
|
||||
2. Copy the env template and edit it:
|
||||
|
||||
```zsh title="Terminal"
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set `DASHBOARD_ORIGIN` and `BETTER_AUTH_SECRET` for your domain. For local development the defaults work as-is.
|
||||
|
||||
3. Start the full stack:
|
||||
|
||||
```zsh title="Terminal"
|
||||
./run-docker-full-stack.sh
|
||||
```
|
||||
|
||||
The script builds and starts Postgres, Redis, MinIO (object storage), the API, and the dashboard. It auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when they are missing.
|
||||
|
||||
4. Verify:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -sS http://localhost:3081/ # API — returns JSON
|
||||
curl -I http://localhost:3082/ # Dashboard — returns 200
|
||||
```
|
||||
|
||||
- Dashboard: http://localhost:3082
|
||||
- API: http://localhost:3081
|
||||
- MinIO Console: http://localhost:9001 (default login: `minioadmin` / `minioadmin`)
|
||||
|
||||
### One `.env`, One Command
|
||||
|
||||
Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Required
|
||||
|
||||
| Variable | Description | Local Default |
|
||||
|----------|-------------|---------------|
|
||||
| `POSTGRES_DB` | Database name | `classroomio` |
|
||||
| `POSTGRES_USER` | Database user | `postgres` |
|
||||
| `POSTGRES_PASSWORD` | Database password | `postgres` |
|
||||
| `BETTER_AUTH_SECRET` | Auth session signing secret | — |
|
||||
| `AUTH_BEARER_TOKEN` | API bearer token | Auto-generated |
|
||||
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (same in both) | Auto-generated |
|
||||
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
|
||||
|
||||
#### Optional — Direct API Access
|
||||
|
||||
Dashboard browser requests use same-origin `/proxy/*` and `/api/auth/*` paths, then the dashboard forwards them to `PRIVATE_SERVER_URL`. Set these only when browsers or third-party clients need to call the API origin directly.
|
||||
|
||||
| Variable | Description | Local Default |
|
||||
|----------|-------------|---------------|
|
||||
| `PUBLIC_SERVER_URL` | Canonical public API URL / direct API fallback | `http://localhost:3081` |
|
||||
| `TRUSTED_ORIGINS` | Comma-separated CORS origins for direct browser-to-API calls | — |
|
||||
|
||||
#### CSP Domains (Runtime)
|
||||
|
||||
CSP domain variables are **runtime** — set them in your `.env` or `docker-compose.yaml` `environment:` section. No image rebuild is needed when changing them.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
|
||||
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
|
||||
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
|
||||
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
|
||||
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
|
||||
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
|
||||
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
|
||||
|
||||
<Callout type="warn" title="CSP Domains">
|
||||
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
|
||||
</Callout>
|
||||
|
||||
#### Optional — Email
|
||||
|
||||
Configure either SMTP or Zoho. Without email config, the app works but cannot send emails.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
|
||||
| `ZOHO_TOKEN` | Zoho/ZeptoMail token (alternative to SMTP) |
|
||||
|
||||
#### Optional — Object Storage
|
||||
|
||||
MinIO is included by default. The startup script auto-configures these — you only need to change them if using custom credentials or an external S3-compatible service.
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `MINIO_ROOT_USER` | `minioadmin` |
|
||||
| `MINIO_ROOT_PASSWORD` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
|
||||
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
|
||||
| `OBJECT_STORAGE_ACCESS_KEY_ID` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | `minioadmin` |
|
||||
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
|
||||
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
|
||||
|
||||
Use `./run-docker-full-stack.sh --no-minio` to exclude MinIO if you do not need uploads.
|
||||
|
||||
<Callout type="error" title="Change default credentials">
|
||||
Do not use `minioadmin` / `minioadmin` in production.
|
||||
</Callout>
|
||||
|
||||
#### Optional — Integrations
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
|
||||
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
|
||||
| `UNSPLASH_API_KEY` | Unsplash image search |
|
||||
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
|
||||
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
|
||||
|
||||
### Common Commands
|
||||
|
||||
```zsh title="Terminal"
|
||||
# Start full stack (builds images, starts all services including MinIO)
|
||||
./run-docker-full-stack.sh
|
||||
|
||||
# Start without rebuilding images
|
||||
./run-docker-full-stack.sh --no-build
|
||||
|
||||
# Start without MinIO (no file uploads)
|
||||
./run-docker-full-stack.sh --no-minio
|
||||
|
||||
# Service status
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml ps
|
||||
|
||||
# Stream logs
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml logs -f api dashboard
|
||||
|
||||
# Rebuild one service
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml up -d --build api
|
||||
|
||||
# Restart after env change (no rebuild needed)
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml restart api dashboard
|
||||
|
||||
# Run DB seed
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml run --rm --entrypoint sh api -c "pnpm --filter @cio/db db:setup -- --seed"
|
||||
|
||||
# Stop
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down
|
||||
|
||||
# Stop and delete all data (database, redis, minio volumes)
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml down -v
|
||||
```
|
||||
|
||||
See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting.
|
||||
|
||||
### Troubleshooting: "No space left on device"
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker system df # check usage
|
||||
docker builder prune -f # free build cache
|
||||
docker image prune -a -f # free unused images
|
||||
docker container prune -f # free stopped containers
|
||||
```
|
||||
|
||||
<Callout type="warn" title="Volume prune deletes data">
|
||||
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
|
||||
</Callout>
|
||||
|
||||
Then restart: `./run-docker-full-stack.sh`
|
||||
|
||||
---
|
||||
|
||||
## Railway
|
||||
|
||||
The quickest hosted deployment is the [Railway Template](https://railway.app/template/dl9RG7?referralCode=8OyIj5).
|
||||
|
||||
[](https://railway.app/template/dl9RG7?referralCode=8OyIj5)
|
||||
|
||||
### cio-api
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
AUTH_BEARER_TOKEN="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
|
||||
# Dashboard URL for invite/email links (e.g. https://app.yourdomain.com)
|
||||
DASHBOARD_ORIGIN="https://app.yourdomain.com"
|
||||
|
||||
# Optional direct public API URL. Normal dashboard auth/API calls use the
|
||||
# dashboard proxy and do not require API/dashboard cookie-domain matching.
|
||||
PUBLIC_SERVER_URL="https://api.yourdomain.com"
|
||||
TRUSTED_ORIGINS=
|
||||
|
||||
# Email (configure one)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
|
||||
# Object storage — Cloudflare R2 (for hosted deployments)
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_ACCESS_KEY=
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY=
|
||||
CLOUDFLARE_BUCKET_DOMAIN=
|
||||
CLOUDFLARE_IMAGE_BUCKET_DOMAIN=
|
||||
```
|
||||
|
||||
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
|
||||
|
||||
### cio-dashboard
|
||||
|
||||
```zsh title="env"
|
||||
ALLOWED_EXTERNAL_DOMAINS="https://*.yourdomain.com,https://fonts.googleapis.com"
|
||||
PRIVATE_APP_HOST="https://app.yourdomain.com"
|
||||
PRIVATE_SERVER_KEY="same-key-for-api-and-dashboard"
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
PRIVATE_SERVER_URL="http://${{api.RAILWAY_PRIVATE_DOMAIN}}:8080"
|
||||
ORIGIN="https://app.yourdomain.com"
|
||||
```
|
||||
|
||||
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
|
||||
|
||||
<Callout title="PRIVATE_SERVER_KEY">
|
||||
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API.
|
||||
</Callout>
|
||||
|
||||
### SMTP Setup
|
||||
|
||||
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
For hosted deployments, Cloudflare R2 handles object storage. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide.
|
||||
|
||||
### SSO (Enterprise)
|
||||
|
||||
SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Coolify
|
||||
description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It deploys ClassroomIO as a **Docker Compose resource** using the pull-based [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml) — so your Coolify host **pulls** the published `classroomio/*` images and never builds anything.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Coolify instance with a connected server.
|
||||
- A domain pointed at that server (Coolify's built-in proxy terminates HTTPS for you).
|
||||
|
||||
## Steps
|
||||
|
||||
1. **New Resource → Docker Compose.** Point Coolify at this repo and the compose path `docker-compose.images.yaml`, or paste the file's contents.
|
||||
|
||||
2. **Set environment variables** (Coolify's UI — these become the compose `.env`). Coolify does **not** run our `run-docker-full-stack.sh`, so generate secrets yourself:
|
||||
|
||||
```zsh title="Required"
|
||||
CIO_VERSION=1.4.2 # pin a release (not "latest") so upgrades are deliberate
|
||||
DASHBOARD_ORIGIN=https://app.yourdomain.com
|
||||
BETTER_AUTH_SECRET=<openssl rand -hex 32>
|
||||
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # must be identical in the api and dashboard services
|
||||
POSTGRES_PASSWORD=<strong password>
|
||||
# Email (required for signup/reset/invites)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
```
|
||||
|
||||
<Callout type="error" title="The stack won't start without strong secrets">
|
||||
`BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are required. The compose file fails fast if
|
||||
either is empty, so it never boots with a predictable session-signing secret.
|
||||
</Callout>
|
||||
|
||||
3. **Assign a domain.** Map your domain to the **dashboard** service (internal port `3082`); Coolify's reverse proxy issues the certificate. The dashboard proxies API calls server-side, so you normally only need the dashboard exposed publicly — the `api` service stays internal.
|
||||
|
||||
4. **Object storage.** Recommended: the **bundled MinIO** — enable its profile with
|
||||
`COMPOSE_PROFILES=minio` and Coolify runs MinIO alongside the stack. Give the MinIO volume
|
||||
persistent storage in Coolify and route its public URL (`OBJECT_STORAGE_PUBLIC_ENDPOINT` /
|
||||
`OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers can load media. Prefer fully-managed
|
||||
storage instead? Point the same `OBJECT_STORAGE_*` variables at external **S3 / Cloudflare R2**
|
||||
and leave the `minio` profile off — see the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
|
||||
|
||||
5. **Deploy.** Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up.
|
||||
|
||||
## Upgrades & rollback
|
||||
|
||||
Bump `CIO_VERSION` to a newer release and redeploy; roll back by setting it to the previous version and redeploying. Because you pin a version, a new release never restarts you onto broken images unattended.
|
||||
|
||||
<Callout type="warn" title="Back up before upgrading">
|
||||
Schema migrations run automatically on API startup. Back up your Postgres (and object storage)
|
||||
before a major upgrade.
|
||||
</Callout>
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: Docker
|
||||
description: Self-host ClassroomIO with Docker — pull pre-built images (recommended) or build from source.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Docker is one way to self-host ClassroomIO. There are two paths:
|
||||
|
||||
- **Run from pre-built images (recommended)** — pull the published `classroomio/{api,dashboard,jobs}` images. You only need one compose file and a `.env`; no repository clone and no heavy local build.
|
||||
- **Build from source** — clone the repo and build the images yourself (needs ~8 GB RAM for the dashboard build).
|
||||
|
||||

|
||||
|
||||
## Run from Pre-built Images (recommended)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker installed and running.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Download the pull-based compose file and an env template:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
|
||||
curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env.example
|
||||
```
|
||||
|
||||
2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP:
|
||||
|
||||
```zsh title=".env"
|
||||
CIO_VERSION=1.4.2 # pin a release (not "latest") so upgrades are deliberate
|
||||
DASHBOARD_ORIGIN=https://app.yourdomain.com
|
||||
BETTER_AUTH_SECRET=<openssl rand -hex 32>
|
||||
PRIVATE_SERVER_KEY=<openssl rand -hex 32> # must be identical in the api and dashboard
|
||||
POSTGRES_PASSWORD=<strong password>
|
||||
# Email (required for signup/reset/invites)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
```
|
||||
|
||||
<Callout type="error" title="Set strong secrets — the stack won't start without them">
|
||||
This path does **not** run `run-docker-full-stack.sh`, so secrets are not auto-generated. You must
|
||||
set `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` yourself — e.g. `openssl rand -hex 32`. The
|
||||
compose file fails fast with a clear error if either is empty, so it never boots with a
|
||||
predictable session-signing secret.
|
||||
</Callout>
|
||||
|
||||
3. Pull and start the stack (MinIO is included via the `minio` profile):
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
|
||||
```
|
||||
|
||||
4. Verify:
|
||||
|
||||
```zsh title="Terminal"
|
||||
curl -sS http://localhost:3081/ # API — returns JSON
|
||||
curl -I http://localhost:3082/ # Dashboard — returns 200
|
||||
```
|
||||
|
||||
- Dashboard: http://localhost:3082
|
||||
- API: http://localhost:3081
|
||||
- MinIO Console: http://localhost:9001 (log in with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` from your `.env`)
|
||||
|
||||
## Build from Source
|
||||
|
||||
Use this if you want to build the images locally (e.g. to run code changes).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Desktop installed and running
|
||||
- `git` installed
|
||||
- ~8 GB RAM available for the dashboard build
|
||||
|
||||
### Steps
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```zsh title="Terminal"
|
||||
git clone https://github.com/classroomio/classroomio.git
|
||||
cd classroomio
|
||||
```
|
||||
|
||||
2. Copy the env template and edit it:
|
||||
|
||||
```zsh title="Terminal"
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set `DASHBOARD_ORIGIN` for your domain. For local development the defaults work as-is — the startup script generates `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` when they are missing, and randomizes the MinIO credentials.
|
||||
|
||||
3. Build and start the full stack:
|
||||
|
||||
```zsh title="Terminal"
|
||||
./run-docker-full-stack.sh --build
|
||||
```
|
||||
|
||||
The script builds and starts Postgres, Redis, MinIO (object storage), the API, the dashboard, and the background **jobs** worker.
|
||||
|
||||
<Callout title="Default is pull, not build">
|
||||
Run without `--build` and the script **pulls** the published `classroomio/*` images at the
|
||||
`CIO_VERSION` in your `.env` instead of building locally — that's the faster default path.
|
||||
</Callout>
|
||||
|
||||
## Upgrading to a New Version
|
||||
|
||||
ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run:
|
||||
|
||||
- Pin an exact release (e.g. `1.4.2`) in production so a new release never restarts you onto changed images unattended.
|
||||
- `latest` = newest stable release. `edge` = bleeding edge (last `main` build).
|
||||
|
||||
**Pre-built images path** — bump `CIO_VERSION` in `.env`, then re-pull and recreate:
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull
|
||||
docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d
|
||||
```
|
||||
|
||||
**Rollback** — set `CIO_VERSION` back to the previous release and run the same two commands.
|
||||
|
||||
**Build-from-source path** — `git pull`, then re-run `./run-docker-full-stack.sh --build` to rebuild.
|
||||
|
||||
<Callout type="warn" title="Back up before upgrading">
|
||||
Database schema migrations run automatically when the API starts. Back up your Postgres volume
|
||||
(and object storage) before a major upgrade, and avoid `docker compose down -v` / `docker volume
|
||||
prune`, which delete your data volumes.
|
||||
</Callout>
|
||||
|
||||
## One `.env`, One Command
|
||||
|
||||
Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `POSTGRES_DB` | Database name | `classroomio` |
|
||||
| `POSTGRES_USER` | Database user | `postgres` |
|
||||
| `POSTGRES_PASSWORD` | Database password | `postgres` |
|
||||
| `BETTER_AUTH_SECRET` | Auth session signing secret (required — stack won't start if empty) | — |
|
||||
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (must be identical in api + dashboard) | Auto-generated by the script |
|
||||
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
|
||||
|
||||
### CSP Domains (Runtime)
|
||||
|
||||
CSP domain variables are **runtime** — set them in your `.env`. No image rebuild is needed when changing them.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOWED_EXTERNAL_DOMAINS` | Overrides all per-directive CSP vars (comma-separated) |
|
||||
| `CSP_SCRIPT_SRC_DOMAINS` | Domains allowed in `script-src` |
|
||||
| `CSP_STYLE_SRC_DOMAINS` | Domains allowed in `style-src` |
|
||||
| `CSP_CONNECT_SRC_DOMAINS` | Domains allowed in `connect-src` |
|
||||
| `CSP_FRAME_SRC_DOMAINS` | Domains allowed in `frame-src` |
|
||||
| `CSP_FONT_SRC_DOMAINS` | Domains allowed in `font-src` |
|
||||
| `CSP_MEDIA_SRC_DOMAINS` | Domains allowed in `img-src` and `media-src` |
|
||||
|
||||
<Callout type="warn" title="CSP Domains">
|
||||
When `PUBLIC_IS_SELFHOSTED=true`, the dashboard starts with only `'self'` allowed. Set `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive vars) with your CDN, video, and font domains. The API does not need to be added for normal dashboard traffic because requests are proxied same-origin. Since these are runtime env vars, you can change them by restarting the container — no rebuild required.
|
||||
</Callout>
|
||||
|
||||
### Optional — Email
|
||||
|
||||
Configure SMTP. Without email config, the app works but cannot send invites, password resets, or notifications.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER` | SMTP server |
|
||||
|
||||
### Optional — Object Storage
|
||||
|
||||
The bundled **MinIO** is included by default and the startup script auto-configures it — you only need to change these for custom credentials or an external S3-compatible service.
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `MINIO_ROOT_USER` | randomized by the script (`minioadmin` if you skip it) |
|
||||
| `MINIO_ROOT_PASSWORD` | randomized by the script (`minioadmin` if you skip it) |
|
||||
| `OBJECT_STORAGE_ENDPOINT` | `http://minio:9000` |
|
||||
| `OBJECT_STORAGE_PUBLIC_ENDPOINT` | `http://localhost:9000` |
|
||||
| `OBJECT_STORAGE_ACCESS_KEY_ID` | matches `MINIO_ROOT_USER` |
|
||||
| `OBJECT_STORAGE_SECRET_ACCESS_KEY` | matches `MINIO_ROOT_PASSWORD` |
|
||||
| `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` |
|
||||
| `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` |
|
||||
|
||||
<Callout title="Using AWS S3 or Cloudflare R2 instead of MinIO">
|
||||
The `OBJECT_STORAGE_*` variables speak the S3 API, so they work with any S3-compatible provider.
|
||||
To use Cloudflare R2 or AWS S3, point `OBJECT_STORAGE_ENDPOINT` at the provider's endpoint and set
|
||||
the matching access key / secret, then start without the bundled MinIO via
|
||||
`./run-docker-full-stack.sh --no-minio`. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
|
||||
</Callout>
|
||||
|
||||
### Optional — Integrations
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google OAuth |
|
||||
| `PRIVATE_APP_HOST`, `PRIVATE_APP_SUBDOMAINS` | Multi-subdomain routing |
|
||||
| `UNSPLASH_API_KEY` | Unsplash image search |
|
||||
| `OPENAI_API_KEY` | Transcription + AI course generation (used by the jobs worker) |
|
||||
| `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) |
|
||||
| `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding |
|
||||
|
||||
## Common Commands
|
||||
|
||||
```zsh title="Terminal"
|
||||
# Start the full stack (pulls pre-built images by default)
|
||||
./run-docker-full-stack.sh
|
||||
|
||||
# Build from source instead of pulling
|
||||
./run-docker-full-stack.sh --build
|
||||
|
||||
# Start without MinIO (requires an external S3-compatible store)
|
||||
./run-docker-full-stack.sh --no-minio
|
||||
```
|
||||
|
||||
See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting.
|
||||
|
||||
## Troubleshooting: "No space left on device"
|
||||
|
||||
```zsh title="Terminal"
|
||||
docker system df # check usage
|
||||
docker builder prune -f # free build cache
|
||||
docker image prune -a -f # free unused images
|
||||
docker container prune -f # free stopped containers
|
||||
```
|
||||
|
||||
<Callout type="warn" title="Volume prune deletes data">
|
||||
Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes.
|
||||
</Callout>
|
||||
|
||||
Then restart: `./run-docker-full-stack.sh`
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Self Hosting",
|
||||
"pages": ["docker", "railway", "coolify"]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Railway
|
||||
description: Deploy ClassroomIO on Railway with the one-click template.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
The quickest hosted deployment is the [Railway Template](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5). It provisions the API, dashboard, and jobs worker services plus the Postgres and Redis plugins for you.
|
||||
|
||||
[](https://railway.com/deploy/classroomio-1?referralCode=8OyIj5)
|
||||
|
||||
After deploying the template, fill in the environment variables for each service below.
|
||||
|
||||
## cio-api
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
BETTER_AUTH_SECRET="<generate-with-openssl-rand-hex-32>"
|
||||
PRIVATE_SERVER_KEY="<generate-with-openssl-rand-hex-32>" # must be identical in cio-api and cio-dashboard
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
|
||||
# Dashboard URL for invite/email links. Reference the dashboard service's Railway domain
|
||||
# so it stays correct automatically. (For a custom domain, hardcode it: https://app.yourdomain.com)
|
||||
DASHBOARD_ORIGIN="https://${{cio-dashboard.RAILWAY_PUBLIC_DOMAIN}}"
|
||||
|
||||
# Optional direct public API URL. Normal dashboard auth/API calls use the
|
||||
# dashboard proxy and do not require API/dashboard cookie-domain matching.
|
||||
PUBLIC_SERVER_URL="https://api.yourdomain.com"
|
||||
TRUSTED_ORIGINS=
|
||||
|
||||
# Email (configure SMTP)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_SENDER=
|
||||
|
||||
# Object storage → the self-hosted cio-minio service (see below)
|
||||
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
|
||||
```
|
||||
|
||||
Optional: add `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for OAuth, `UNSPLASH_API_KEY` for image search, or `LICENSE_KEY` for enterprise features.
|
||||
|
||||
## cio-dashboard
|
||||
|
||||
```zsh title="env"
|
||||
# RAILWAY_PUBLIC_DOMAIN here = this dashboard service's own public domain.
|
||||
# For a custom domain, hardcode app.yourdomain.com in ORIGIN / PRIVATE_APP_HOST instead.
|
||||
ORIGIN="https://${{RAILWAY_PUBLIC_DOMAIN}}"
|
||||
PRIVATE_APP_HOST="${{RAILWAY_PUBLIC_DOMAIN}}"
|
||||
PUBLIC_IS_SELFHOSTED="true"
|
||||
PRIVATE_SERVER_URL="http://${{cio-api.RAILWAY_PRIVATE_DOMAIN}}:${{cio-api.PORT}}"
|
||||
PRIVATE_SERVER_KEY="${{cio-api.PRIVATE_SERVER_KEY}}" # same value as cio-api
|
||||
ALLOWED_EXTERNAL_DOMAINS="https://fonts.googleapis.com"
|
||||
```
|
||||
|
||||
Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control.
|
||||
|
||||
## cio-jobs (background worker)
|
||||
|
||||
The jobs worker has **no public domain** — it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API:
|
||||
|
||||
```zsh title="env"
|
||||
DATABASE_URL="${{db.DATABASE_URL}}"
|
||||
REDIS_URL="${{Redis.REDIS_URL}}"
|
||||
|
||||
# Object storage — same cio-minio service as the API
|
||||
OBJECT_STORAGE_ENDPOINT=http://${{cio-minio.RAILWAY_PRIVATE_DOMAIN}}:9000
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT=https://storage.yourdomain.com
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID=${{cio-minio.MINIO_ROOT_USER}}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY=${{cio-minio.MINIO_ROOT_PASSWORD}}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE=true
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL=https://storage.yourdomain.com/media
|
||||
|
||||
# Email (worker sends invites, notifications, etc.)
|
||||
SMTP_HOST= SMTP_PORT= SMTP_USER= SMTP_PASSWORD= SMTP_SENDER=
|
||||
|
||||
# Transcription + OpenAI-backed AI course generation
|
||||
OPENAI_API_KEY=
|
||||
```
|
||||
|
||||
<Callout title="PRIVATE_SERVER_KEY">
|
||||
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard. It authenticates server-side dashboard requests to the API. Referencing `${{cio-api.PRIVATE_SERVER_KEY}}` in the dashboard keeps them in sync.
|
||||
</Callout>
|
||||
|
||||
## cio-minio (object storage)
|
||||
|
||||
We recommend a self-hosted **MinIO** service for object storage. Add it as a new service from
|
||||
the Docker image `minio/minio:latest`:
|
||||
|
||||
- **Start command:** `server /data --console-address ":9001"`
|
||||
- **Volume:** attach a Railway Volume mounted at **`/data`** — **required**, or every upload is
|
||||
wiped on each redeploy.
|
||||
- **Public domain:** e.g. `storage.yourdomain.com` with **target port `9000`** (the S3 API).
|
||||
Railway also gives it a private domain (`cio-minio.railway.internal:9000`) for the api/jobs.
|
||||
|
||||
```zsh title="env"
|
||||
MINIO_ROOT_USER=<strong user>
|
||||
MINIO_ROOT_PASSWORD=<strong password>
|
||||
# Optional (silences console redirect warnings behind the proxy):
|
||||
MINIO_SERVER_URL=https://storage.yourdomain.com
|
||||
```
|
||||
|
||||
The api and jobs services reach MinIO over **two** endpoints: the private `OBJECT_STORAGE_ENDPOINT`
|
||||
(server uploads/reads) and the public `OBJECT_STORAGE_PUBLIC_ENDPOINT` + `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`
|
||||
(browser-facing presigned downloads and public images) — already wired in the env blocks above.
|
||||
`OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO.
|
||||
|
||||
### Seed the buckets (one-time)
|
||||
|
||||
Railway has no `minio-init`, so create the buckets once with [`mc`](https://min.io/docs/minio/linux/reference/minio-mc.html)
|
||||
(run locally against the public domain):
|
||||
|
||||
```zsh title="Terminal"
|
||||
mc alias set cio https://storage.yourdomain.com "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
|
||||
mc mb cio/videos cio/documents cio/media --ignore-existing
|
||||
mc anonymous set download cio/media # media bucket = public read (images)
|
||||
```
|
||||
|
||||
<Callout type="warn" title="MinIO is your durability boundary">
|
||||
Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your
|
||||
uploads. Back it up. If you'd rather not operate storage, use external **Cloudflare R2 / AWS S3**
|
||||
instead (R2 has no egress fees): point the same `OBJECT_STORAGE_*` vars at the provider and skip
|
||||
the cio-minio service. See the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2.
|
||||
</Callout>
|
||||
|
||||
## SMTP Setup
|
||||
|
||||
You need SMTP for email sending. See [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider setup.
|
||||
|
||||
## SSO (Enterprise)
|
||||
|
||||
SSO requires a license key. Set `LICENSE_KEY` in your API environment. [Book a call](http://cal.com/classroomio/enterprise) to get a license.
|
||||
@@ -1,35 +0,0 @@
|
||||
FROM node:20-bookworm-slim AS base
|
||||
|
||||
# ffmpeg is needed for media probe / thumbnail / audio-extract / transcode tasks.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace manifest and lockfile first for cache reuse.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||
COPY apps/jobs/package.json apps/jobs/
|
||||
COPY packages/jobs/package.json packages/jobs/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/utils/package.json packages/utils/
|
||||
COPY packages/tsconfig/package.json packages/tsconfig/
|
||||
COPY packages/email/package.json packages/email/
|
||||
COPY packages/question-types/package.json packages/question-types/
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @cio/jobs-worker...
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm --filter @cio/utils build \
|
||||
&& pnpm --filter @cio/db build \
|
||||
&& pnpm --filter @cio/jobs build \
|
||||
&& pnpm --filter @cio/jobs-worker build
|
||||
|
||||
# Run all worker entrypoints by default. Override CMD to scale media / transcribe
|
||||
# / maintenance independently.
|
||||
CMD ["node", "apps/jobs/dist/index.js"]
|
||||
@@ -17,7 +17,7 @@ Today we are happy to share that you can now easily self-host classroomio and us
|
||||
|
||||
If you are interested in just trying out the product, it is advisable to use the [cloud version](https://app.classroomio.com). You can use almost all the features for free on the cloud version unless you need more students. Right now the main reason you'd have to upgrade is because your students are growing. If you don't want to upgrade and/or face this, then you'd be fine self hosting the whole application.
|
||||
|
||||
For self-hosting, we have written up a [guide that uses Railway](https://classroomio.com/docs/quickstart/self-hosting) to help deploy our stack and you get to manage the servers on Railway as you wish. We hope this is a good step in helping you educate your audience at ease.
|
||||
For self-hosting, we have written up a [guide that uses Railway](https://classroomio.com/docs/self-hosted/railway) to help deploy our stack and you get to manage the servers on Railway as you wish. We hope this is a good step in helping you educate your audience at ease.
|
||||
|
||||
Below is a demo showing you step by step how to self host the project
|
||||
|
||||
@@ -25,6 +25,6 @@ Below is a demo showing you step by step how to self host the project
|
||||
|
||||
### Next steps
|
||||
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for both our api and dashboard projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
We want to support self-hosting using a Coolify template or just using a Dockerfile. We currently have Dockerfiles for our api, dashboard, and jobs worker projects however contributions from the open source community would help us in properly testing and improving them.
|
||||
|
||||
If you prefer deployment with Docker, do checkout our [docker-compose.yaml](https://github.com/classroomio/classroomio/blob/main/docker-compose.yaml) file to get started and we can kick off a conversation on Github.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# ClassroomIO — pull-based stack (pre-built images, no local build).
|
||||
#
|
||||
# This is the same topology as docker-compose.yaml but the app services pull
|
||||
# published images from Docker Hub instead of building from source. You only need
|
||||
# THIS file + a .env — no repository checkout, no 8 GB dashboard build.
|
||||
#
|
||||
# CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env pull
|
||||
# CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d
|
||||
#
|
||||
# CIO_VERSION pins the release. Use a specific version (e.g. 1.4.2) in production;
|
||||
# "latest" = newest stable release, "edge" = bleeding edge (last main build).
|
||||
#
|
||||
# KEEP IN SYNC with docker-compose.yaml: the api/dashboard/jobs `environment`,
|
||||
# healthchecks, depends_on, and volumes must match the build-based file. The only
|
||||
# intended difference is build: -> image:.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cio-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-classroomio}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# Require a successful query so we only pass once Postgres is out of recovery
|
||||
# (pg_isready can pass while DB is still in recovery, causing 57P03 on sign-in)
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 15
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: cio-redis
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: cio-minio
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
profiles:
|
||||
- minio
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: cio-minio-init
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
echo 'Waiting for MinIO to be ready...';
|
||||
sleep 5;
|
||||
mc alias set local http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin};
|
||||
mc mb local/videos --ignore-existing;
|
||||
mc mb local/documents --ignore-existing;
|
||||
mc mb local/media --ignore-existing;
|
||||
mc anonymous set download local/media;
|
||||
echo 'MinIO buckets created';
|
||||
exit 0;
|
||||
"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
profiles:
|
||||
- minio
|
||||
|
||||
api:
|
||||
image: classroomio/api:${CIO_VERSION:-latest}
|
||||
container_name: cio-api
|
||||
ports:
|
||||
- "3081:3081"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
# node:slim has no curl/wget; use Node's global fetch (Node 20+).
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3081
|
||||
PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081}
|
||||
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
|
||||
REDIS_URL: redis://redis:6379
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-docker-local-client-id}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-docker-local-client-secret}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-}
|
||||
SMTP_SENDER: ${SMTP_SENDER:-}
|
||||
# Object storage (S3-compatible). Works with the bundled MinIO, AWS S3, or Cloudflare R2
|
||||
# — for R2, point OBJECT_STORAGE_ENDPOINT at your R2 endpoint.
|
||||
OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-}
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
|
||||
# Auth secrets — required. Set strong values in .env (the pull path has no script to
|
||||
# generate them); the stack refuses to start otherwise.
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
|
||||
DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-}
|
||||
LICENSE_KEY: ${LICENSE_KEY:-}
|
||||
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set a strong BETTER_AUTH_SECRET in .env}
|
||||
SKIP_DB_SETUP: ${SKIP_DB_SETUP:-}
|
||||
restart: unless-stopped
|
||||
|
||||
dashboard:
|
||||
image: classroomio/dashboard:${CIO_VERSION:-latest}
|
||||
container_name: cio-dashboard
|
||||
ports:
|
||||
- "3082:3082"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3082
|
||||
ORIGIN: ${DASHBOARD_ORIGIN:-http://localhost:3082}
|
||||
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
|
||||
PRIVATE_SERVER_URL: ${PRIVATE_SERVER_URL:-http://api:3081}
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
|
||||
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
|
||||
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
|
||||
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
|
||||
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
|
||||
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
|
||||
CSP_CONNECT_SRC_DOMAINS: ${CSP_CONNECT_SRC_DOMAINS:-}
|
||||
CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-}
|
||||
CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-}
|
||||
CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker (BullMQ). Drains the Redis queue: video processing, transcription,
|
||||
# AI course generation, and emails.
|
||||
jobs:
|
||||
image: classroomio/jobs:${CIO_VERSION:-latest}
|
||||
container_name: cio-jobs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
REDIS_URL: redis://redis:6379
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
|
||||
# Object storage (workers stream object bytes). Mirrors the api service.
|
||||
OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-}
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-}
|
||||
OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
|
||||
# Bucket names match those created by minio-init above.
|
||||
OBJECT_STORAGE_BUCKET_VIDEOS: ${OBJECT_STORAGE_BUCKET_VIDEOS:-videos}
|
||||
OBJECT_STORAGE_BUCKET_DOCUMENTS: ${OBJECT_STORAGE_BUCKET_DOCUMENTS:-documents}
|
||||
OBJECT_STORAGE_BUCKET_MEDIA: ${OBJECT_STORAGE_BUCKET_MEDIA:-media}
|
||||
# Email (worker sends invites, notifications, etc.).
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_SENDER: ${SMTP_SENDER:-}
|
||||
# Transcription + OpenAI-backed agent runs.
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
minio-data:
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.api
|
||||
container_name: cio-api
|
||||
ports:
|
||||
@@ -91,13 +91,25 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
# node:slim has no curl/wget; use Node's global fetch (Node 20+).
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3081
|
||||
PUBLIC_SERVER_URL: ${PUBLIC_SERVER_URL:-http://localhost:3081}
|
||||
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
|
||||
PRIVATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
|
||||
REDIS_URL: redis://redis:6379
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-docker-local-client-id}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-docker-local-client-secret}
|
||||
@@ -106,30 +118,27 @@ services:
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-}
|
||||
SMTP_SENDER: ${SMTP_SENDER:-}
|
||||
CLOUDFLARE_BUCKET_DOMAIN: ${CLOUDFLARE_BUCKET_DOMAIN:-}
|
||||
CLOUDFLARE_ACCESS_KEY: ${CLOUDFLARE_ACCESS_KEY:-}
|
||||
CLOUDFLARE_SECRET_ACCESS_KEY: ${CLOUDFLARE_SECRET_ACCESS_KEY:-}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID:-}
|
||||
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: ${CLOUDFLARE_IMAGE_BUCKET_DOMAIN:-}
|
||||
CLOUDFLARE_RENDERING_API_KEY: ${CLOUDFLARE_RENDERING_API_KEY:-}
|
||||
# Object storage (S3-compatible). Works with the bundled MinIO, AWS S3, or Cloudflare R2
|
||||
# — for R2, point OBJECT_STORAGE_ENDPOINT at your R2 endpoint.
|
||||
OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-}
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
|
||||
AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-local-dev-api-key}
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:-local-dev-api-key}
|
||||
# Auth secrets — required. Generated by run-docker-full-stack.sh; set strong values
|
||||
# in .env if you start the stack with `docker compose` directly.
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
|
||||
DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-}
|
||||
LICENSE_KEY: ${LICENSE_KEY:-}
|
||||
UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set a strong BETTER_AUTH_SECRET in .env}
|
||||
SKIP_DB_SETUP: ${SKIP_DB_SETUP:-}
|
||||
restart: unless-stopped
|
||||
|
||||
dashboard:
|
||||
build:
|
||||
context: ..
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.dashboard
|
||||
args:
|
||||
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
|
||||
@@ -138,17 +147,16 @@ services:
|
||||
- "3082:3082"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3082
|
||||
ORIGIN: ${DASHBOARD_ORIGIN:-http://localhost:3082}
|
||||
PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true}
|
||||
PRIVATE_SERVER_URL: ${PRIVATE_SERVER_URL:-http://api:3081}
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:-local-dev-api-key}
|
||||
PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env}
|
||||
PRIVATE_APP_HOST: ${PRIVATE_APP_HOST:-localhost}
|
||||
PRIVATE_APP_SUBDOMAINS: ${PRIVATE_APP_SUBDOMAINS:-app}
|
||||
AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-}
|
||||
ALLOWED_EXTERNAL_DOMAINS: ${ALLOWED_EXTERNAL_DOMAINS:-}
|
||||
CSP_SCRIPT_SRC_DOMAINS: ${CSP_SCRIPT_SRC_DOMAINS:-}
|
||||
CSP_STYLE_SRC_DOMAINS: ${CSP_STYLE_SRC_DOMAINS:-}
|
||||
@@ -158,6 +166,44 @@ services:
|
||||
CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# Background worker (BullMQ). Drains the Redis queue: video processing, transcription,
|
||||
# AI course generation, and emails.
|
||||
jobs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.jobs
|
||||
container_name: cio-jobs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
REDIS_URL: redis://redis:6379
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-classroomio}
|
||||
# Object storage (workers stream object bytes). Mirrors the api service.
|
||||
OBJECT_STORAGE_ENDPOINT: ${OBJECT_STORAGE_ENDPOINT:-}
|
||||
OBJECT_STORAGE_PUBLIC_ENDPOINT: ${OBJECT_STORAGE_PUBLIC_ENDPOINT:-}
|
||||
OBJECT_STORAGE_REGION: ${OBJECT_STORAGE_REGION:-}
|
||||
OBJECT_STORAGE_ACCESS_KEY_ID: ${OBJECT_STORAGE_ACCESS_KEY_ID:-}
|
||||
OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-}
|
||||
OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true}
|
||||
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-}
|
||||
# Bucket names match those created by minio-init above.
|
||||
OBJECT_STORAGE_BUCKET_VIDEOS: ${OBJECT_STORAGE_BUCKET_VIDEOS:-videos}
|
||||
OBJECT_STORAGE_BUCKET_DOCUMENTS: ${OBJECT_STORAGE_BUCKET_DOCUMENTS:-documents}
|
||||
OBJECT_STORAGE_BUCKET_MEDIA: ${OBJECT_STORAGE_BUCKET_MEDIA:-media}
|
||||
# Email (worker sends invites, notifications, etc.).
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_SENDER: ${SMTP_SENDER:-}
|
||||
# Transcription + OpenAI-backed agent runs.
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
+11
-1
@@ -4,12 +4,22 @@ WORKDIR /app
|
||||
|
||||
# Keep install non-interactive and deterministic in CI/container contexts.
|
||||
ENV CI=true
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
# Fetch deps from the lockfile first (cached unless pnpm-lock.yaml changes); the
|
||||
# store is baked into this layer and reused by the offline install below.
|
||||
COPY pnpm-lock.yaml ./
|
||||
# Patches referenced by package.json#pnpm.patchedDependencies must be present for `pnpm fetch`.
|
||||
COPY patches ./patches
|
||||
RUN pnpm fetch --store-dir /pnpm/store
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts --shamefully-hoist --filter @cio/api...
|
||||
RUN pnpm install --offline --frozen-lockfile --ignore-scripts --shamefully-hoist \
|
||||
--store-dir /pnpm/store --filter @cio/api...
|
||||
RUN find . -name 'tsconfig.tsbuildinfo' -delete
|
||||
RUN pnpm -r --filter @cio/api... build
|
||||
|
||||
|
||||
@@ -3,14 +3,24 @@ FROM node:20.19.3-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV CI=true
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
ARG PUBLIC_IS_SELFHOSTED=true
|
||||
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
# Fetch deps from the lockfile first (cached unless pnpm-lock.yaml changes); the
|
||||
# store is baked into this layer and reused by the offline install below.
|
||||
COPY pnpm-lock.yaml ./
|
||||
# Patches referenced by package.json#pnpm.patchedDependencies must be present for `pnpm fetch`.
|
||||
COPY patches ./patches
|
||||
RUN pnpm fetch --store-dir /pnpm/store
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @cio/dashboard...
|
||||
RUN pnpm install --offline --frozen-lockfile --ignore-scripts \
|
||||
--store-dir /pnpm/store --filter @cio/dashboard...
|
||||
RUN find . -name 'tsconfig.tsbuildinfo' -delete
|
||||
RUN NODE_OPTIONS="--max-old-space-size=8072" \
|
||||
PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED}" \
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
# ffmpeg is needed for media probe / thumbnail / audio-extract / transcode tasks.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Keep install non-interactive and deterministic in CI/container contexts.
|
||||
ENV CI=true
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
# Fetch deps from the lockfile first (cached unless pnpm-lock.yaml changes); the
|
||||
# store is baked into this layer and reused by the offline install below. `pnpm
|
||||
# fetch` populates the store for the whole lockfile, so no hand-maintained package
|
||||
# list. Mirrors the api/dashboard images.
|
||||
COPY pnpm-lock.yaml ./
|
||||
# Patches referenced by package.json#pnpm.patchedDependencies must be present for `pnpm fetch`.
|
||||
COPY patches ./patches
|
||||
RUN pnpm fetch --store-dir /pnpm/store
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --offline --frozen-lockfile --ignore-scripts \
|
||||
--store-dir /pnpm/store --filter @cio/jobs-worker...
|
||||
RUN find . -name 'tsconfig.tsbuildinfo' -delete
|
||||
# Build the worker and all its workspace deps in topological order.
|
||||
RUN pnpm -r --filter @cio/jobs-worker... build
|
||||
|
||||
# Run all worker entrypoints by default. Override CMD to scale media / transcribe
|
||||
# / maintenance independently.
|
||||
CMD ["node", "apps/jobs/dist/index.js"]
|
||||
+46
-19
@@ -1,7 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker Hub Push Script for ClassroomIO
|
||||
# This script builds and pushes the API and Dashboard images to Docker Hub
|
||||
# Builds and pushes the API, Dashboard, and Jobs worker images to Docker Hub.
|
||||
# Uses buildx for native multi-arch (linux/amd64 + linux/arm64).
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
@@ -9,6 +10,14 @@ set -e # Exit on error
|
||||
DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME:-classroomio}" # Change this to your Docker Hub username
|
||||
VERSION="${VERSION:-latest}" # Can be overridden with VERSION env var
|
||||
PUBLIC_IS_SELFHOSTED="${PUBLIC_IS_SELFHOSTED:-true}"
|
||||
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
|
||||
# Only a real semver release should also move the `latest` tag — building edge/main/dev
|
||||
# must NOT clobber the stable `latest` pointer (mirrors docker-publish.yml's tag policy).
|
||||
# Use `if` (not `&&`) so the function always returns 0 even when it emits no tag — a helper
|
||||
# returning non-zero inside a $(...) argument is fragile under `set -e` / inherit_errexit.
|
||||
is_release_version() { [[ "${VERSION}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; }
|
||||
latest_arg() { if is_release_version; then printf -- '-t %s:latest' "$1"; fi; }
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
@@ -20,37 +29,53 @@ echo -e "${BLUE}================================${NC}"
|
||||
echo -e "${BLUE}ClassroomIO Docker Hub Publisher${NC}"
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
echo ""
|
||||
echo "Platforms: ${PLATFORMS}"
|
||||
echo ""
|
||||
|
||||
# Build and push API
|
||||
echo -e "${GREEN}Building API image...${NC}"
|
||||
docker build \
|
||||
# Ensure a buildx builder exists (multi-arch requires buildx + QEMU).
|
||||
if ! docker buildx inspect cio-builder >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Creating buildx builder 'cio-builder'...${NC}"
|
||||
docker buildx create --name cio-builder --use >/dev/null
|
||||
else
|
||||
docker buildx use cio-builder
|
||||
fi
|
||||
|
||||
# Build and push API (multi-arch images must be pushed in one buildx step).
|
||||
echo -e "${GREEN}Building + pushing API image...${NC}"
|
||||
docker buildx build \
|
||||
--platform "${PLATFORMS}" \
|
||||
-f docker/Dockerfile.api \
|
||||
-t ${DOCKERHUB_USERNAME}/api:${VERSION} \
|
||||
-t ${DOCKERHUB_USERNAME}/api:latest \
|
||||
$(latest_arg ${DOCKERHUB_USERNAME}/api) \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo -e "${GREEN}Pushing API image to Docker Hub...${NC}"
|
||||
docker push ${DOCKERHUB_USERNAME}/api:${VERSION}
|
||||
docker push ${DOCKERHUB_USERNAME}/api:latest
|
||||
|
||||
echo -e "${GREEN}✓ API image published successfully!${NC}"
|
||||
echo ""
|
||||
|
||||
# Build and push Dashboard
|
||||
echo -e "${GREEN}Building Dashboard image...${NC}"
|
||||
echo -e "${GREEN}Building + pushing Dashboard image...${NC}"
|
||||
echo "Dashboard build arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED}"
|
||||
docker build \
|
||||
docker buildx build \
|
||||
--platform "${PLATFORMS}" \
|
||||
-f docker/Dockerfile.dashboard \
|
||||
--build-arg PUBLIC_IS_SELFHOSTED=${PUBLIC_IS_SELFHOSTED} \
|
||||
-t ${DOCKERHUB_USERNAME}/dashboard:${VERSION} \
|
||||
-t ${DOCKERHUB_USERNAME}/dashboard:latest \
|
||||
$(latest_arg ${DOCKERHUB_USERNAME}/dashboard) \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo -e "${GREEN}Pushing Dashboard image to Docker Hub...${NC}"
|
||||
docker push ${DOCKERHUB_USERNAME}/dashboard:${VERSION}
|
||||
docker push ${DOCKERHUB_USERNAME}/dashboard:latest
|
||||
|
||||
echo -e "${GREEN}✓ Dashboard image published successfully!${NC}"
|
||||
echo ""
|
||||
|
||||
# Build and push Jobs worker (required for video/transcription/AI-generation/email).
|
||||
echo -e "${GREEN}Building + pushing Jobs worker image...${NC}"
|
||||
docker buildx build \
|
||||
--platform "${PLATFORMS}" \
|
||||
-f docker/Dockerfile.jobs \
|
||||
-t ${DOCKERHUB_USERNAME}/jobs:${VERSION} \
|
||||
$(latest_arg ${DOCKERHUB_USERNAME}/jobs) \
|
||||
--push \
|
||||
.
|
||||
echo -e "${GREEN}✓ Jobs image published successfully!${NC}"
|
||||
|
||||
# Summary
|
||||
echo -e "${BLUE}================================${NC}"
|
||||
@@ -59,7 +84,9 @@ echo -e "${BLUE}================================${NC}"
|
||||
echo ""
|
||||
echo "API Image: ${DOCKERHUB_USERNAME}/api:${VERSION}"
|
||||
echo "Dashboard Image: ${DOCKERHUB_USERNAME}/dashboard:${VERSION}"
|
||||
echo "Jobs Image: ${DOCKERHUB_USERNAME}/jobs:${VERSION}"
|
||||
echo ""
|
||||
echo "To pull these images:"
|
||||
echo " docker pull ${DOCKERHUB_USERNAME}/api:${VERSION}"
|
||||
echo " docker pull ${DOCKERHUB_USERNAME}/dashboard:${VERSION}
|
||||
echo " docker pull ${DOCKERHUB_USERNAME}/dashboard:${VERSION}"
|
||||
echo " docker pull ${DOCKERHUB_USERNAME}/jobs:${VERSION}"
|
||||
|
||||
@@ -57,9 +57,12 @@ Check your Docker Hub repository at:
|
||||
|
||||
The workflow will automatically publish images when:
|
||||
|
||||
1. **Push to main/master branch**: Creates a `latest` tag
|
||||
2. **Create a version tag**: Creates versioned tags
|
||||
3. **Manual trigger**: Use the Actions tab in GitHub
|
||||
1. **Push to `main`**: Publishes the **`edge`** tag only (does NOT move `latest`).
|
||||
2. **Push a version tag `vX.Y.Z`**: Publishes `X.Y.Z`, `X.Y`, `X`, and `latest`.
|
||||
3. **Manual trigger**: Use the Actions tab in GitHub.
|
||||
|
||||
Every build is **boot smoke-tested** (started against ephemeral postgres + redis, healthcheck
|
||||
verified) by the `smoke` job *before* anything is pushed — a broken image never reaches users.
|
||||
|
||||
#### Create a version tag and push:
|
||||
|
||||
@@ -102,42 +105,45 @@ docker pull classroomio/dashboard:latest
|
||||
|
||||
### Deploy with Compose
|
||||
|
||||
For self-hosting with Docker Compose, see [SELF_HOST.md](SELF_HOST.md). The setup uses a single root `.env` file and `docker/docker-compose.yaml`.
|
||||
For self-hosting with Docker Compose, see [SELF_HOST.md](SELF_HOST.md). The setup uses a single root `.env` file and `docker-compose.yaml`.
|
||||
|
||||
## Versioning Strategy
|
||||
## Versioning Strategy (stable-releases + edge)
|
||||
|
||||
### Recommended Tags:
|
||||
The published tags and what they mean:
|
||||
|
||||
- `latest`: Most recent stable build
|
||||
- `v0.1.0`: Specific semantic version
|
||||
- `v1.0`: Minor version
|
||||
- `v1`: Major version
|
||||
- `main`: Latest main branch build
|
||||
- `dev`: Development builds
|
||||
| Tag | Published by | Meaning |
|
||||
|-----|--------------|---------|
|
||||
| `1.4.2` (exact) | a `v1.4.2` git tag | Immutable, smoke-tested release — **what production should pin** |
|
||||
| `1.4`, `1` | a `v1.4.2` git tag | Latest patch/minor of that line |
|
||||
| `latest` | a `v*` git tag only | The newest *released* version |
|
||||
| `edge` | every push to `main` | Bleeding edge; may be unstable |
|
||||
|
||||
### Example: Publishing a new version
|
||||
The key rule: **`latest` is never moved by a routine merge to `main`** — only a deliberate release
|
||||
tag moves it. Self-hosters pin `CIO_VERSION` (see [SELF_HOST.md](SELF_HOST.md)) and upgrade on their
|
||||
own schedule.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
Versioning is driven by git tags; the CI workflow does the rest (build multi-arch → smoke-test →
|
||||
push `X.Y.Z`/`X.Y`/`X`/`latest`).
|
||||
|
||||
```bash
|
||||
# Build with multiple tags
|
||||
docker build -f docker/Dockerfile.api \
|
||||
-t classroomio/api:latest \
|
||||
-t classroomio/api:v1.2.0 \
|
||||
-t classroomio/api:v1.2 \
|
||||
-t classroomio/api:v1 \
|
||||
.
|
||||
# Bump version + create the vX.Y.Z tag from the repo's changelog tooling
|
||||
pnpm release # standard-version: bumps package.json, writes CHANGELOG, creates the git tag
|
||||
|
||||
# Push all tags
|
||||
docker push classroomio/api:latest
|
||||
docker push classroomio/api:v1.2.0
|
||||
docker push classroomio/api:v1.2
|
||||
docker push classroomio/api:v1
|
||||
# Push the tag — this triggers the smoke-tested publish
|
||||
git push --follow-tags origin main
|
||||
```
|
||||
|
||||
To publish manually from a laptop instead, `./docker/docker-push.sh` builds + pushes all three
|
||||
images multi-arch (set `VERSION=1.4.2`).
|
||||
|
||||
## Image Sizes and Build Times
|
||||
|
||||
- **API Image**: ~400-600 MB (Node.js Alpine + dependencies)
|
||||
- **Dashboard Image**: ~600-800 MB (Node.js Alpine + SvelteKit build)
|
||||
- **Build Time**: 5-10 minutes per image (depending on your machine)
|
||||
- **API Image**: ~400-600 MB (Node.js Debian slim + dependencies)
|
||||
- **Dashboard Image**: ~600-800 MB (Node.js Debian slim + SvelteKit build)
|
||||
- **Jobs Image**: ~500-700 MB (Node.js Debian slim + ffmpeg + dependencies)
|
||||
- **Build Time**: 5-10 minutes per image (depending on your machine). Multi-arch (amd64 + arm64) builds take longer.
|
||||
|
||||
## Multi-Architecture Support
|
||||
|
||||
|
||||
+155
-20
@@ -15,9 +15,63 @@ cp .env.example .env
|
||||
./run-docker-full-stack.sh
|
||||
```
|
||||
|
||||
The script builds and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), and **dashboard** (`localhost:3082`). The **api** container runs database schema setup on startup (skip with `SKIP_DB_SETUP=true`).
|
||||
The script builds and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), **dashboard** (`localhost:3082`), and the **jobs** worker (background processing). Database schema setup runs automatically inside the `api` container on startup (see [`docker/entrypoint-api.sh`](../entrypoint-api.sh); skip with `SKIP_DB_SETUP=true`).
|
||||
|
||||
It auto-generates secure values for `AUTH_BEARER_TOKEN` and `PRIVATE_SERVER_KEY` when they are missing.
|
||||
It auto-generates secure values for `PRIVATE_SERVER_KEY` and `BETTER_AUTH_SECRET` when they are missing or left at placeholders, and randomizes MinIO credentials on first provision.
|
||||
|
||||
> **The jobs worker is required, not optional.** Without it, video processing, captions/transcription, AI course generation, and most emails are enqueued in Redis but never run — uploads stay stuck "processing" with no error. See [Background Jobs Worker](#background-jobs-worker) below.
|
||||
|
||||
## Run from Pre-built Images (no build)
|
||||
|
||||
The Quick Start builds images from source — that needs the full repo and ~8 GB RAM for the
|
||||
dashboard build. Most self-hosters should instead **pull** the published `classroomio/{api,dashboard,jobs}`
|
||||
images. You only need [`docker-compose.images.yaml`](../../docker-compose.images.yaml) and a `.env`:
|
||||
|
||||
```bash
|
||||
# 1. Get just the compose file + env template (no full clone required)
|
||||
curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml
|
||||
curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env.example
|
||||
# 2. Edit .env — set CIO_VERSION (pin a release!), DASHBOARD_ORIGIN, secrets, SMTP
|
||||
# 3. Pull and start
|
||||
CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env pull
|
||||
CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d
|
||||
```
|
||||
|
||||
From a full clone you can also use the helper: `./run-docker-full-stack.sh --images`.
|
||||
|
||||
> Secrets aren't auto-generated in this path (the helper script does that). Set `BETTER_AUTH_SECRET`
|
||||
> and `PRIVATE_SERVER_KEY` to strong random values yourself.
|
||||
|
||||
## Versioning & Upgrades
|
||||
|
||||
Published images use a **stable-releases + edge** tag policy:
|
||||
|
||||
| Tag | Meaning | Use it for |
|
||||
|-----|---------|-----------|
|
||||
| `1.4.2` (exact) | An immutable, smoke-tested release | **Production — always pin this** |
|
||||
| `1.4`, `1` | Latest patch/minor of that line | Tracking a line |
|
||||
| `latest` | The newest *released* version | Trying it out |
|
||||
| `edge` | The last `main` build (may be unstable) | Testing upcoming changes |
|
||||
|
||||
`latest` only moves when a real release is tagged — a routine merge to `main` publishes `edge`, never
|
||||
`latest`. **Pin `CIO_VERSION` to an exact version in production** so a new release can't break you
|
||||
unattended.
|
||||
|
||||
```bash
|
||||
# Upgrade: bump the version, pull, recreate
|
||||
CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env pull
|
||||
CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env up -d
|
||||
|
||||
# Rollback: set CIO_VERSION back to the previous release and repeat
|
||||
```
|
||||
|
||||
Schema migrations run automatically on `api` startup (`db:setup`), so upgrades need no manual DB
|
||||
step. If a release consolidates ("squashes") older migrations, the startup baseline detects an
|
||||
existing instance and adopts the new baseline as already-applied — your database is upgraded in
|
||||
place without re-running schema that already exists.
|
||||
|
||||
(Maintainers: every published image is boot-smoke-tested in CI before it ships — see
|
||||
[`PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md).)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -25,30 +79,33 @@ All Docker services read from a single root `.env` file. See [`.env.example`](..
|
||||
|
||||
Key points:
|
||||
|
||||
- **Required:** `BETTER_AUTH_SECRET`, `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN`.
|
||||
- **Required:** `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN` (set this to your real domain in production — it also drives public media URLs, see [Production Object Storage](#production-object-storage)).
|
||||
- **API routing:** Browser dashboard calls go to the dashboard origin and are proxied to `PRIVATE_SERVER_URL` (default: `http://api:3081`). You do not need API/dashboard cookie-domain matching for normal dashboard auth.
|
||||
- **Direct API access (optional):** Set `PUBLIC_SERVER_URL` and `TRUSTED_ORIGINS` only if browsers or third-party clients need to call the API origin directly.
|
||||
- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed. The API does not need to be added for normal dashboard calls.
|
||||
- **Auth cookies:** No cookie-domain env is needed. The dashboard proxy makes auth first-party and Better Auth sets host-only dashboard cookies.
|
||||
- **Auto-generated:** `AUTH_BEARER_TOKEN`, `PRIVATE_SERVER_KEY` (by `./run-docker-full-stack.sh`).
|
||||
- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script).
|
||||
- **Optional:** Email (SMTP or Zoho), Google OAuth, Unsplash, `LICENSE_KEY` (enterprise).
|
||||
- **Auto-generated:** `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET` (by `./run-docker-full-stack.sh`). A strong value you set yourself is never overwritten.
|
||||
- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script, with randomized MinIO credentials).
|
||||
- **Email (effectively required):** SMTP (or Zoho). See [Email](#email) — without it, signup verification, password reset, and invites do not send.
|
||||
- **Optional:** Google OAuth, Unsplash, `LICENSE_KEY` (enterprise).
|
||||
|
||||
`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard — the script ensures this.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml ps
|
||||
docker compose --env-file .env -p classroomio -f docker-compose.yaml ps
|
||||
curl -sS http://localhost:3081/ # API — returns JSON
|
||||
curl -I http://localhost:3082/ # Dashboard — returns 200
|
||||
```
|
||||
|
||||
## Seed Demo Data
|
||||
|
||||
Schema setup runs automatically on `api` startup. To additionally seed demo data, run the
|
||||
seed script inside the running `api` container:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml \
|
||||
run --rm --entrypoint sh api -c "pnpm --filter @cio/db db:setup -- --seed"
|
||||
docker exec cio-api pnpm --filter @cio/db db:setup:seed
|
||||
```
|
||||
|
||||
## Command Reference
|
||||
@@ -56,13 +113,13 @@ docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml \
|
||||
All commands use this prefix — abbreviated as `dc` below:
|
||||
|
||||
```
|
||||
dc = docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml
|
||||
dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml
|
||||
```
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Full stack (with MinIO) | `./run-docker-full-stack.sh` |
|
||||
| Skip image rebuild | `./run-docker-full-stack.sh --no-build` |
|
||||
| Full stack (pulls pre-built images, with MinIO) | `./run-docker-full-stack.sh` |
|
||||
| Build from source instead of pulling | `./run-docker-full-stack.sh --build` |
|
||||
| Exclude MinIO | `./run-docker-full-stack.sh --no-minio` |
|
||||
| API-only smoke test | `dc up --build -d postgres redis api` |
|
||||
| Service status | `dc ps` |
|
||||
@@ -86,7 +143,7 @@ docker exec cio-api env | sort
|
||||
docker exec cio-dashboard env | sort
|
||||
```
|
||||
|
||||
Container names: `cio-postgres`, `cio-redis`, `cio-api`, `cio-dashboard`, `cio-minio`.
|
||||
Container names: `cio-postgres`, `cio-redis`, `cio-api`, `cio-dashboard`, `cio-jobs`, `cio-minio`.
|
||||
|
||||
## MinIO (Object Storage)
|
||||
|
||||
@@ -98,10 +155,12 @@ MinIO is included by default. The startup script auto-configures env vars, start
|
||||
To start MinIO manually (e.g. after `--no-minio`):
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yaml --profile minio up -d
|
||||
docker compose -f docker-compose.yaml --profile minio up -d
|
||||
```
|
||||
|
||||
**Security:** Change `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` in `.env` for production. Use the same values for `OBJECT_STORAGE_ACCESS_KEY_ID` / `OBJECT_STORAGE_SECRET_ACCESS_KEY`.
|
||||
- **Web UI:** `http://localhost:9001` (credentials are the randomized `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` written to your `.env`).
|
||||
|
||||
**Security:** The startup script randomizes `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` on first provision and mirrors them into `OBJECT_STORAGE_ACCESS_KEY_ID` / `OBJECT_STORAGE_SECRET_ACCESS_KEY`. If you change them, keep both pairs in sync.
|
||||
|
||||
### MinIO Web UI Not Loading
|
||||
|
||||
@@ -109,21 +168,97 @@ If port 9001 does not respond, recreate the container:
|
||||
|
||||
```bash
|
||||
docker rm -f cio-minio
|
||||
docker compose -f docker/docker-compose.yaml --profile minio up -d
|
||||
docker compose -f docker-compose.yaml --profile minio up -d
|
||||
```
|
||||
|
||||
Data is preserved in the `minio-data` volume.
|
||||
|
||||
## Background Jobs Worker
|
||||
|
||||
The `jobs` service (`cio-jobs`) is a BullMQ worker that processes everything ClassroomIO does
|
||||
asynchronously:
|
||||
|
||||
- **Video processing** — probe, thumbnails, audio extraction, transcoding
|
||||
- **Transcription / captions** — via OpenAI Whisper (needs `OPENAI_API_KEY`)
|
||||
- **AI course generation** — the "generate a course with AI" agent
|
||||
- **Emails** — invites, notifications, and other non-auth emails
|
||||
- **Maintenance** — periodic cleanup
|
||||
|
||||
It shares Redis and the database with the API. It is started automatically by the full-stack
|
||||
script. If it is not running, jobs accumulate in Redis and silently never complete.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -p classroomio -f docker-compose.yaml logs -f jobs
|
||||
```
|
||||
|
||||
> Note: the in-course **AI tutor chat** runs synchronously inside the API and does not need the
|
||||
> worker. Course *generation*, transcription, and media processing do.
|
||||
|
||||
## Email
|
||||
|
||||
Email is **effectively required**: signup verification and password reset are sent by the API,
|
||||
and invites/notifications are sent by the worker. Configure one of:
|
||||
|
||||
- **SMTP:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SENDER`
|
||||
- **Zoho / ZeptoMail:** `ZOHO_TOKEN`
|
||||
|
||||
Both the `api` and `jobs` services read these. Without them, auth and invite flows appear to
|
||||
succeed in the UI but no email is delivered (`ECONNREFUSED` in logs).
|
||||
|
||||
## Production Object Storage
|
||||
|
||||
The bundled MinIO is fine for a single host. Two things matter for a real deployment:
|
||||
|
||||
1. **Public media URLs must point at your domain, not `localhost`.** Media URLs are embedded in
|
||||
pages served to browsers, so `http://localhost:9000/...` breaks for every remote visitor. The
|
||||
startup script derives `OBJECT_STORAGE_PUBLIC_ENDPOINT` and
|
||||
`OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` from `DASHBOARD_ORIGIN` when it is a real domain — make
|
||||
sure your reverse proxy routes `<your-domain>/media` to MinIO on port 9000 (or set those two
|
||||
variables explicitly).
|
||||
2. **External S3 / Cloudflare R2 instead of MinIO.** Run with `--no-minio` and set the
|
||||
`OBJECT_STORAGE_*` variables (endpoint, public endpoint, access key, secret, buckets) to your
|
||||
provider. The startup script fails fast if `--no-minio` is used without an
|
||||
`OBJECT_STORAGE_ENDPOINT`, so you cannot accidentally launch with no storage.
|
||||
|
||||
## Backups & Persistence
|
||||
|
||||
All state lives in named Docker volumes: `postgres-data`, `redis-data`, `minio-data`
|
||||
(see `docker-compose.yaml`). **`docker compose ... down -v` deletes them — and all your
|
||||
data.** Back up regularly:
|
||||
|
||||
```bash
|
||||
# Database (most important)
|
||||
docker exec cio-postgres pg_dump -U postgres classroomio > backup-$(date +%F).sql
|
||||
|
||||
# Object storage — copy the minio-data volume, or use `mc mirror` to an off-box bucket
|
||||
```
|
||||
|
||||
Restore the database into a fresh stack with `psql`/`pg_restore` against `cio-postgres`.
|
||||
|
||||
## Scaling & Repeated Startups
|
||||
|
||||
`db:setup` runs on every `api` container start (idempotent schema sync). If you run **multiple
|
||||
API replicas**, set `SKIP_DB_SETUP=true` on all but one to avoid concurrent migration races, and
|
||||
run setup once out-of-band. The worker queues can also be scaled independently by overriding the
|
||||
`jobs` container command with the per-queue `start:*` scripts (e.g. `start:media`,
|
||||
`start:media-transcribe`) — see `apps/jobs/package.json`.
|
||||
|
||||
## HLS Video Playback
|
||||
|
||||
Signed HLS playback (`HLS_SIGNING_SECRET`) is validated by the `tenant-router` Cloudflare Worker,
|
||||
which is **not** part of this compose stack. Self-hosted deployments serve media via the object
|
||||
storage public URL above; signed-HLS delivery is out of scope for the compose setup.
|
||||
|
||||
## Rebuild After Code Changes
|
||||
|
||||
This setup builds images from source (no live hot reload). Code changes require a rebuild:
|
||||
|
||||
```bash
|
||||
# One service
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml up -d --build api
|
||||
docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api
|
||||
|
||||
# Both (e.g. shared packages/* changed)
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml up -d --build api dashboard
|
||||
docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api dashboard
|
||||
|
||||
# Full stack
|
||||
./run-docker-full-stack.sh
|
||||
@@ -154,7 +289,7 @@ Postgres has not finished starting. Wait 30-60 seconds and retry. The compose he
|
||||
Same root cause as disk full. Free space using the steps above and restart MinIO:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -p classroomio -f docker/docker-compose.yaml restart minio
|
||||
docker compose --env-file .env -p classroomio -f docker-compose.yaml restart minio
|
||||
```
|
||||
|
||||
### SMTP Errors in Logs
|
||||
@@ -170,5 +305,5 @@ lsof -nP -iTCP:3082 -sTCP:LISTEN
|
||||
|
||||
## Related
|
||||
|
||||
- [Self-hosting docs](https://classroomio.com/docs/quickstart/self-hosting) — published guide with Railway + Docker instructions
|
||||
- [Self-hosting docs](https://classroomio.com/docs/self-hosted/docker) — published guide with Docker, Railway, and Coolify instructions
|
||||
- [`docker/docs/PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md) — publishing Docker images to Docker Hub
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Pre-publish smoke test: boot a freshly built image and confirm it actually starts
|
||||
# before we push it to Docker Hub. Run from CI against ephemeral postgres + redis.
|
||||
#
|
||||
# bash docker/smoke-test.sh <service> <image-ref>
|
||||
# service = api | dashboard | jobs
|
||||
# image-ref = local image tag built with --load (e.g. cio-smoke-api:test)
|
||||
#
|
||||
# Exits non-zero (failing the publish) if the container crashes or never becomes ready.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${1:?usage: smoke-test.sh <api|dashboard|jobs> <image-ref>}"
|
||||
IMAGE="${2:?missing image ref}"
|
||||
|
||||
DB_URL="postgresql://postgres:postgres@localhost:5432/classroomio"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
NAME="cio-smoke-${SERVICE}"
|
||||
|
||||
cleanup() { docker rm -f "${NAME}" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
container_alive() { [ -n "$(docker ps -q -f name="^${NAME}$")" ]; }
|
||||
dump_and_fail() {
|
||||
echo "SMOKE FAIL (${SERVICE}): $1"
|
||||
echo "----- container logs (tail) -----"
|
||||
docker logs "${NAME}" 2>&1 | tail -60 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${SERVICE}" in
|
||||
api)
|
||||
docker run -d --name "${NAME}" --network host \
|
||||
-e NODE_ENV=production -e PORT=3081 \
|
||||
-e DATABASE_URL="${DB_URL}" -e PRIVATE_DATABASE_URL="${DB_URL}" -e REDIS_URL="${REDIS_URL}" \
|
||||
-e BETTER_AUTH_SECRET=smoke-test-secret-not-for-production \
|
||||
-e PRIVATE_SERVER_KEY=smoke \
|
||||
"${IMAGE}"
|
||||
# entrypoint runs db:setup against the ephemeral DB (also exercises migrations), then serves.
|
||||
for _ in $(seq 1 40); do
|
||||
container_alive || dump_and_fail "container exited during startup"
|
||||
if curl -fsS --max-time 5 http://localhost:3081/ >/dev/null 2>&1; then
|
||||
echo "SMOKE OK (api): healthcheck passed"; exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
dump_and_fail "api did not become healthy on :3081 within timeout"
|
||||
;;
|
||||
|
||||
jobs)
|
||||
docker run -d --name "${NAME}" --network host \
|
||||
-e NODE_ENV=production -e DATABASE_URL="${DB_URL}" -e REDIS_URL="${REDIS_URL}" \
|
||||
"${IMAGE}"
|
||||
for _ in $(seq 1 30); do
|
||||
container_alive || dump_and_fail "worker container exited during startup"
|
||||
if docker logs "${NAME}" 2>&1 | grep -q "all-workers-running"; then
|
||||
echo "SMOKE OK (jobs): workers booted"; exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
dump_and_fail "jobs did not report 'all-workers-running' within timeout"
|
||||
;;
|
||||
|
||||
dashboard)
|
||||
docker run -d --name "${NAME}" --network host \
|
||||
-e NODE_ENV=production -e PORT=3082 -e ORIGIN=http://localhost:3082 \
|
||||
-e PUBLIC_IS_SELFHOSTED=true \
|
||||
-e PRIVATE_SERVER_URL=http://localhost:3081 -e PRIVATE_SERVER_KEY=smoke \
|
||||
"${IMAGE}"
|
||||
# Boot check: the SvelteKit server should listen even if the API is absent.
|
||||
for _ in $(seq 1 30); do
|
||||
container_alive || dump_and_fail "dashboard container exited during startup"
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:3082/ 2>/dev/null || true)"
|
||||
# Accept any listening response (2xx/3xx, or the expected 404 when the API is absent),
|
||||
# but FAIL on 5xx — a server-error on every route means a broken build, not a healthy boot.
|
||||
if [ -n "${code}" ] && [ "${code}" != "000" ] && [[ "${code}" != 5* ]]; then
|
||||
echo "SMOKE OK (dashboard): server listening (HTTP ${code})"; exit 0
|
||||
fi
|
||||
if [[ "${code}" == 5* ]]; then
|
||||
dump_and_fail "dashboard returned server error HTTP ${code}"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
dump_and_fail "dashboard did not listen on :3082 within timeout"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "unknown service: ${SERVICE}" >&2; exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -43,7 +43,6 @@ const envSchema = z.object({
|
||||
PRIVATE_SERVER_KEY: z.string().optional(),
|
||||
PROJECT_ID_VERCEL: z.string().optional(),
|
||||
TEAM_ID_VERCEL: z.string().optional(),
|
||||
AUTH_BEARER_TOKEN: z.string().optional(),
|
||||
UNSPLASH_API_KEY: z.string().optional(),
|
||||
/** When 'true', instance runs in self-hosted mode: single org, auto-enroll as student, no org switching */
|
||||
PUBLIC_IS_SELFHOSTED: z.string().optional(),
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"dev": "tsc --watch",
|
||||
"db": "drizzle-kit",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:baseline": "tsx src/scripts/baseline.ts",
|
||||
"db:reset": "tsx src/scripts/db-reset.ts --skip-seed",
|
||||
"db:setup": "tsx src/scripts/db-setup.ts",
|
||||
"db:setup:seed": "tsx src/scripts/db-setup.ts --seed",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,644 +0,0 @@
|
||||
-- CREATE TYPE "public"."COURSE_TYPE" AS ENUM('SELF_PACED', 'LIVE_CLASS');--> statement-breakpoint
|
||||
-- CREATE TYPE "public"."COURSE_VERSION" AS ENUM('V1', 'V2');--> statement-breakpoint
|
||||
-- CREATE TYPE "public"."LOCALE" AS ENUM('en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da');--> statement-breakpoint
|
||||
-- CREATE TYPE "public"."PLAN" AS ENUM('EARLY_ADOPTER', 'ENTERPRISE', 'BASIC');--> statement-breakpoint
|
||||
-- CREATE TABLE "analytics_login_events" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "user_id" uuid NOT NULL,
|
||||
-- "logged_in_at" timestamp with time zone DEFAULT now(),
|
||||
-- CONSTRAINT "analytics_login_events_user_id_unique" UNIQUE("user_id")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "analytics_login_events" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "apps_poll" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "question" text,
|
||||
-- "authorId" uuid,
|
||||
-- "isPublic" boolean,
|
||||
-- "status" varchar DEFAULT 'draft',
|
||||
-- "expiration" timestamp with time zone,
|
||||
-- "courseId" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "apps_poll_option" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "apps_poll_option_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "updated_at" timestamp with time zone,
|
||||
-- "poll_id" uuid,
|
||||
-- "label" varchar
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_option" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "apps_poll_submission" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "apps_poll_submision_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "poll_option_id" bigint,
|
||||
-- "selected_by_id" uuid,
|
||||
-- "poll_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_submission" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "community_answer" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT extensions.gen_random_uuid() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "question_id" bigint,
|
||||
-- "body" varchar,
|
||||
-- "author_id" bigint,
|
||||
-- "votes" bigint,
|
||||
-- "author_profile_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "community_answer" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "community_question" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "community_question_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "title" varchar,
|
||||
-- "body" text,
|
||||
-- "author_id" bigint,
|
||||
-- "votes" bigint DEFAULT '0',
|
||||
-- "organization_id" uuid,
|
||||
-- "slug" text,
|
||||
-- "author_profile_id" uuid,
|
||||
-- "course_id" uuid NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "community_question" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "course" (
|
||||
-- "title" varchar NOT NULL,
|
||||
-- "description" varchar NOT NULL,
|
||||
-- "overview" varchar DEFAULT 'Welcome to this amazing course 🚀 ',
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "group_id" uuid,
|
||||
-- "is_template" boolean DEFAULT true,
|
||||
-- "logo" text DEFAULT '' NOT NULL,
|
||||
-- "slug" varchar,
|
||||
-- "metadata" jsonb DEFAULT '{"goals":"","description":"","requirements":""}'::jsonb NOT NULL,
|
||||
-- "cost" bigint DEFAULT '0',
|
||||
-- "currency" varchar DEFAULT 'USD' NOT NULL,
|
||||
-- "banner_image" text,
|
||||
-- "is_published" boolean DEFAULT false,
|
||||
-- "is_certificate_downloadable" boolean DEFAULT false,
|
||||
-- "certificate_theme" text,
|
||||
-- "status" text DEFAULT 'ACTIVE' NOT NULL,
|
||||
-- "type" "COURSE_TYPE" DEFAULT 'LIVE_CLASS',
|
||||
-- "version" "COURSE_VERSION" DEFAULT 'V1' NOT NULL,
|
||||
-- CONSTRAINT "course_slug_key" UNIQUE("slug")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "course" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "course_newsfeed" (
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "author_id" uuid,
|
||||
-- "content" text,
|
||||
-- "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
-- "course_id" uuid,
|
||||
-- "reaction" jsonb DEFAULT '{"clap":[],"smile":[],"thumbsup":[],"thumbsdown":[]}'::jsonb,
|
||||
-- "is_pinned" boolean DEFAULT false NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "course_newsfeed_comment" (
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "author_id" uuid,
|
||||
-- "content" text,
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "course_newsfeed_comment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "course_newsfeed_id" uuid,
|
||||
-- CONSTRAINT "course_newsfeed_comment_id_key" UNIQUE("id")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed_comment" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "currency" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "currency_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "name" varchar
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- CREATE TABLE "exercise" (
|
||||
-- "title" varchar NOT NULL,
|
||||
-- "description" varchar,
|
||||
-- "lesson_id" uuid,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "due_by" timestamp
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "exercise" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "group" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "name" varchar NOT NULL,
|
||||
-- "description" text,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "organization_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "group" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "group_attendance" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "group_attendance_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "course_id" uuid,
|
||||
-- "student_id" uuid,
|
||||
-- "is_present" boolean DEFAULT false,
|
||||
-- "lesson_id" uuid NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "group_attendance" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "groupmember" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "group_id" uuid NOT NULL,
|
||||
-- "role_id" bigint NOT NULL,
|
||||
-- "profile_id" uuid,
|
||||
-- "email" varchar,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "assigned_student_id" varchar,
|
||||
-- CONSTRAINT "unique_entries" UNIQUE("group_id","profile_id","email"),
|
||||
-- CONSTRAINT "unique_group_email" UNIQUE("group_id","email"),
|
||||
-- CONSTRAINT "unique_group_profile" UNIQUE("group_id","profile_id")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "groupmember" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson" (
|
||||
-- "note" varchar,
|
||||
-- "video_url" varchar,
|
||||
-- "slide_url" varchar,
|
||||
-- "course_id" uuid NOT NULL,
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "title" varchar NOT NULL,
|
||||
-- "public" boolean DEFAULT false,
|
||||
-- "lesson_at" timestamp with time zone DEFAULT now(),
|
||||
-- "teacher_id" uuid,
|
||||
-- "is_complete" boolean DEFAULT false,
|
||||
-- "call_url" text,
|
||||
-- "order" bigint,
|
||||
-- "is_unlocked" boolean DEFAULT false,
|
||||
-- "videos" jsonb DEFAULT '[]'::jsonb,
|
||||
-- "section_id" uuid,
|
||||
-- "documents" jsonb DEFAULT '[]'::jsonb
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson_comment" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "lesson_comment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "lesson_id" uuid,
|
||||
-- "groupmember_id" uuid,
|
||||
-- "comment" text
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_comment" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson_completion" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "lesson_completion_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "lesson_id" uuid,
|
||||
-- "profile_id" uuid,
|
||||
-- "is_complete" boolean DEFAULT false,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- CONSTRAINT "unique_lesson_profile" UNIQUE("lesson_id","profile_id")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_completion" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson_language" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "lesson_language_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "content" text,
|
||||
-- "lesson_id" uuid DEFAULT gen_random_uuid(),
|
||||
-- "locale" "LOCALE" DEFAULT 'en'
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_language" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson_language_history" (
|
||||
-- "id" serial PRIMARY KEY NOT NULL,
|
||||
-- "lesson_language_id" integer,
|
||||
-- "old_content" text,
|
||||
-- "new_content" text,
|
||||
-- "timestamp" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_language_history" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "lesson_section" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "title" varchar,
|
||||
-- "order" bigint DEFAULT '0',
|
||||
-- "course_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_section" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "option" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "option_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "label" varchar NOT NULL,
|
||||
-- "is_correct" boolean DEFAULT false NOT NULL,
|
||||
-- "question_id" bigint NOT NULL,
|
||||
-- "value" uuid DEFAULT extensions.gen_random_uuid(),
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now()
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "option" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "organization" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "name" varchar NOT NULL,
|
||||
-- "siteName" text,
|
||||
-- "avatar_url" text,
|
||||
-- "settings" jsonb DEFAULT '{}'::jsonb,
|
||||
-- "landingpage" jsonb DEFAULT '{}'::jsonb,
|
||||
-- "theme" text,
|
||||
-- "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
-- "customization" json DEFAULT '{"apps":{"poll":true,"comments":true},"course":{"grading":true,"newsfeed":true},"dashboard":{"exercise":true,"community":true,"bannerText":"","bannerImage":""}}'::json NOT NULL,
|
||||
-- "is_restricted" boolean DEFAULT false NOT NULL,
|
||||
-- "customCode" text,
|
||||
-- "customDomain" text,
|
||||
-- "favicon" text,
|
||||
-- "isCustomDomainVerified" boolean DEFAULT false,
|
||||
-- CONSTRAINT "organization_siteName_key" UNIQUE("siteName"),
|
||||
-- CONSTRAINT "organization_customDomain_key" UNIQUE("customDomain")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "organization" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "organization_contacts" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "organization_contacts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "email" text,
|
||||
-- "phone" text,
|
||||
-- "name" text,
|
||||
-- "message" text,
|
||||
-- "organization_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "organization_contacts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "organization_emaillist" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "organization_emaillist_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "email" text,
|
||||
-- "organization_id" uuid
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "organization_emaillist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "organization_plan" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "organization_plan_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "activated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "org_id" uuid,
|
||||
-- "plan_name" "PLAN",
|
||||
-- "is_active" boolean,
|
||||
-- "deactivated_at" timestamp with time zone,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "payload" jsonb,
|
||||
-- "triggered_by" bigint,
|
||||
-- "provider" text DEFAULT 'lmz',
|
||||
-- "subscription_id" text,
|
||||
-- CONSTRAINT "organization_plan_subscription_id_key" UNIQUE("subscription_id")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "organization_plan" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "organizationmember" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "organizationmember_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "organization_id" uuid NOT NULL,
|
||||
-- "role_id" bigint NOT NULL,
|
||||
-- "profile_id" uuid,
|
||||
-- "email" text,
|
||||
-- "verified" boolean DEFAULT false,
|
||||
-- "created_at" timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "organizationmember" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "profile" (
|
||||
-- "id" uuid PRIMARY KEY NOT NULL,
|
||||
-- "fullname" text NOT NULL,
|
||||
-- "username" text NOT NULL,
|
||||
-- "avatar_url" text DEFAULT 'https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/avatar.png',
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "email" varchar,
|
||||
-- "can_add_course" boolean DEFAULT true,
|
||||
-- "role" varchar,
|
||||
-- "goal" varchar,
|
||||
-- "source" varchar,
|
||||
-- "metadata" json,
|
||||
-- "telegram_chat_id" bigint,
|
||||
-- "is_email_verified" boolean DEFAULT false,
|
||||
-- "verified_at" timestamp with time zone,
|
||||
-- "locale" "LOCALE" DEFAULT 'en',
|
||||
-- "is_restricted" boolean DEFAULT false NOT NULL,
|
||||
-- CONSTRAINT "profile_username_key" UNIQUE("username"),
|
||||
-- CONSTRAINT "profile_email_key" UNIQUE("email")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "profile" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "question" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "question_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "question_type_id" bigint NOT NULL,
|
||||
-- "title" varchar NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "exercise_id" uuid NOT NULL,
|
||||
-- "name" uuid DEFAULT extensions.gen_random_uuid(),
|
||||
-- "points" double precision,
|
||||
-- "order" bigint
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "question" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "question_answer" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "question_answer_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "answers" varchar[],
|
||||
-- "question_id" bigint NOT NULL,
|
||||
-- "open_answer" text,
|
||||
-- "group_member_id" uuid NOT NULL,
|
||||
-- "submission_id" uuid,
|
||||
-- "point" bigint DEFAULT '0'
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "question_answer" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "question_type" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "question_type_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "label" varchar NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "typename" varchar
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "question_type" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "quiz" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT extensions.gen_random_uuid() NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "title" text,
|
||||
-- "questions" json,
|
||||
-- "timelimit" varchar DEFAULT '10s',
|
||||
-- "theme" varchar DEFAULT 'standard',
|
||||
-- "organization_id" uuid NOT NULL
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "quiz" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "quiz_play" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "play_quiz_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "quiz_id" uuid,
|
||||
-- "players" json DEFAULT '[]'::json,
|
||||
-- "started" boolean DEFAULT false,
|
||||
-- "currentQuestionId" bigint DEFAULT '0',
|
||||
-- "showCurrentQuestionAnswer" boolean DEFAULT false,
|
||||
-- "isLastQuestion" boolean,
|
||||
-- "step" text DEFAULT 'CONNECT_TO_PLAY',
|
||||
-- "studentStep" text DEFAULT 'PIN_SETUP',
|
||||
-- "pin" text,
|
||||
-- CONSTRAINT "quiz_play_pin_key" UNIQUE("pin")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "quiz_play" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "role" (
|
||||
-- "type" varchar NOT NULL,
|
||||
-- "description" varchar,
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "role_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "created_at" timestamp with time zone DEFAULT now()
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "role" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "submission" (
|
||||
-- "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
-- "reviewer_id" bigint,
|
||||
-- "status_id" bigint DEFAULT '1',
|
||||
-- "total" bigint DEFAULT '0',
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- "updated_at" timestamp with time zone DEFAULT now(),
|
||||
-- "exercise_id" uuid NOT NULL,
|
||||
-- "submitted_by" uuid,
|
||||
-- "course_id" uuid,
|
||||
-- "feedback" text
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "submission" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "submissionstatus" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "submission_status_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "label" varchar NOT NULL,
|
||||
-- "updated_at" timestamp with time zone DEFAULT now()
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "submissionstatus" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
-- CREATE TABLE "test_tenant" (
|
||||
-- "id" serial PRIMARY KEY NOT NULL,
|
||||
-- "details" text
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- CREATE TABLE "video_transcripts" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "video_transcripts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
-- "muse_svid" text,
|
||||
-- "transcript" text,
|
||||
-- "downloaded" boolean DEFAULT false,
|
||||
-- "link" text
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- CREATE TABLE "waitinglist" (
|
||||
-- "id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "waitinglist_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854776000 START WITH 1 CACHE 1),
|
||||
-- "email" varchar NOT NULL,
|
||||
-- "created_at" timestamp with time zone DEFAULT now(),
|
||||
-- CONSTRAINT "constraint_name" UNIQUE("email")
|
||||
-- );
|
||||
-- --> statement-breakpoint
|
||||
-- ALTER TABLE "analytics_login_events" ADD CONSTRAINT "analytics_login_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll" ADD CONSTRAINT "apps_poll_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll" ADD CONSTRAINT "apps_poll_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_option" ADD CONSTRAINT "apps_poll_option_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "public"."apps_poll"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_submission" ADD CONSTRAINT "apps_poll_submission_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "public"."apps_poll"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_submission" ADD CONSTRAINT "apps_poll_submission_poll_option_id_fkey" FOREIGN KEY ("poll_option_id") REFERENCES "public"."apps_poll_option"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "apps_poll_submission" ADD CONSTRAINT "apps_poll_submission_selected_by_id_fkey" FOREIGN KEY ("selected_by_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_answer" ADD CONSTRAINT "community_answer_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."organizationmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_answer" ADD CONSTRAINT "community_answer_author_profile_id_fkey" FOREIGN KEY ("author_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_answer" ADD CONSTRAINT "community_answer_question_id_fkey" FOREIGN KEY ("question_id") REFERENCES "public"."community_question"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_question" ADD CONSTRAINT "community_question_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."organizationmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_question" ADD CONSTRAINT "community_question_author_profile_id_fkey" FOREIGN KEY ("author_profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_question" ADD CONSTRAINT "community_question_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "community_question" ADD CONSTRAINT "community_question_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "course" ADD CONSTRAINT "course_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed" ADD CONSTRAINT "course_newsfeed_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed" ADD CONSTRAINT "course_newsfeed_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed_comment" ADD CONSTRAINT "course_newsfeed_comment_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "course_newsfeed_comment" ADD CONSTRAINT "course_newsfeed_comment_course_newsfeed_id_fkey" FOREIGN KEY ("course_newsfeed_id") REFERENCES "public"."course_newsfeed"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "exercise" ADD CONSTRAINT "exercise_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "public"."lesson"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "group" ADD CONSTRAINT "group_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "group_attendance" ADD CONSTRAINT "group_attendance_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "group_attendance" ADD CONSTRAINT "group_attendance_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "groupmember" ADD CONSTRAINT "groupmember_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."group"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "groupmember" ADD CONSTRAINT "groupmember_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "groupmember" ADD CONSTRAINT "groupmember_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson" ADD CONSTRAINT "lesson_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson" ADD CONSTRAINT "lesson_teacher_id_fkey" FOREIGN KEY ("teacher_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson" ADD CONSTRAINT "public_lesson_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."lesson_section"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_comment" ADD CONSTRAINT "lesson_comment_groupmember_id_fkey" FOREIGN KEY ("groupmember_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_comment" ADD CONSTRAINT "lesson_comment_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "public"."lesson"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_completion" ADD CONSTRAINT "lesson_completion_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "public"."lesson"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_completion" ADD CONSTRAINT "lesson_completion_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_language" ADD CONSTRAINT "public_lesson_language_lesson_id_fkey" FOREIGN KEY ("lesson_id") REFERENCES "public"."lesson"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_language_history" ADD CONSTRAINT "public_lesson_language_history_lesson_language_id_fkey" FOREIGN KEY ("lesson_language_id") REFERENCES "public"."lesson_language"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
-- ALTER TABLE "lesson_section" ADD CONSTRAINT "public_lesson_section_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint
|
||||
-- ALTER TABLE "option" ADD CONSTRAINT "option_question_id_fkey" FOREIGN KEY ("question_id") REFERENCES "public"."question"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organization_contacts" ADD CONSTRAINT "organization_contacts_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organization_emaillist" ADD CONSTRAINT "organization_emaillist_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organization_plan" ADD CONSTRAINT "organization_plan_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organization_plan" ADD CONSTRAINT "organization_plan_triggered_by_fkey" FOREIGN KEY ("triggered_by") REFERENCES "public"."organizationmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organizationmember" ADD CONSTRAINT "organizationmember_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organizationmember" ADD CONSTRAINT "organizationmember_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "public"."profile"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "organizationmember" ADD CONSTRAINT "organizationmember_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "profile" ADD CONSTRAINT "profile_id_fkey" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "question" ADD CONSTRAINT "question_exercise_id_fkey" FOREIGN KEY ("exercise_id") REFERENCES "public"."exercise"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "question" ADD CONSTRAINT "question_question_type_id_fkey" FOREIGN KEY ("question_type_id") REFERENCES "public"."question_type"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "question_answer" ADD CONSTRAINT "question_answer_group_member_id_fkey" FOREIGN KEY ("group_member_id") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "question_answer" ADD CONSTRAINT "question_answer_question_id_fkey" FOREIGN KEY ("question_id") REFERENCES "public"."question"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "question_answer" ADD CONSTRAINT "question_answer_submission_id_fkey" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "quiz" ADD CONSTRAINT "quiz_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "quiz_play" ADD CONSTRAINT "quiz_play_quiz_id_fkey" FOREIGN KEY ("quiz_id") REFERENCES "public"."quiz"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "submission" ADD CONSTRAINT "submission_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "public"."course"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "submission" ADD CONSTRAINT "submission_exercise_id_fkey" FOREIGN KEY ("exercise_id") REFERENCES "public"."exercise"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "submission" ADD CONSTRAINT "submission_status_id_fkey" FOREIGN KEY ("status_id") REFERENCES "public"."submissionstatus"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- ALTER TABLE "submission" ADD CONSTRAINT "submission_submitted_by_fkey" FOREIGN KEY ("submitted_by") REFERENCES "public"."groupmember"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
-- CREATE INDEX "idx_analytics_login_events_logged_in_at" ON "analytics_login_events" USING btree ("logged_in_at" timestamptz_ops);--> statement-breakpoint
|
||||
-- CREATE INDEX "idx_analytics_login_events_user_id" ON "analytics_login_events" USING btree ("user_id" uuid_ops);--> statement-breakpoint
|
||||
-- CREATE VIEW "public"."dash_org_stats" AS (SELECT gp.organization_id AS org_id, count(DISTINCT course.id) AS no_of_courses, count(DISTINCT gm.profile_id) AS enrolled_students FROM course JOIN "group" gp ON gp.id = course.group_id LEFT JOIN groupmember gm ON gm.group_id = gp.id AND gm.role_id = 3 WHERE course.status = 'ACTIVE'::text GROUP BY gp.organization_id);--> statement-breakpoint
|
||||
-- CREATE VIEW "public"."lesson_versions" AS (SELECT llh.old_content, llh.new_content, llh."timestamp", ll.locale, ll.lesson_id FROM lesson_language_history llh JOIN lesson_language ll ON ll.id = llh.lesson_language_id);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable delete for users based on user_id" ON "analytics_login_events" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = user_id));--> statement-breakpoint
|
||||
-- CREATE POLICY "Users can insert their own login events" ON "analytics_login_events" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete only your own poll" ON "apps_poll" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = ( SELECT groupmember.profile_id
|
||||
-- FROM groupmember
|
||||
-- WHERE (groupmember.id = apps_poll."authorId"))));--> statement-breakpoint
|
||||
-- CREATE POLICY "Update only your own" ON "apps_poll" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "apps_poll" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be course member to SELECT" ON "apps_poll" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "apps_poll_option" AS PERMISSIVE FOR INSERT TO public WITH CHECK (is_user_in_course_group_or_admin(( SELECT groupmember.group_id
|
||||
-- FROM groupmember
|
||||
-- WHERE (groupmember.id = ( SELECT apps_poll."authorId"
|
||||
-- FROM apps_poll
|
||||
-- WHERE (apps_poll.id = apps_poll_option.poll_id)
|
||||
-- LIMIT 1))
|
||||
-- LIMIT 1)));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to UPDATE" ON "apps_poll_option" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a teacher to DELETE" ON "apps_poll_option" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be course member to SELECT" ON "apps_poll_option" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can read" ON "apps_poll_submission" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete your own submission" ON "apps_poll_submission" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "apps_poll_submission" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Update your own submission" ON "apps_poll_submission" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "community_answer" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete your own answer" ON "community_answer" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "community_answer" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Update your own answer" ON "community_answer" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "community_question" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete your own question" ON "community_question" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "community_question" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Update your own question" ON "community_question" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable access to all users if PUBLIC or to course members when " ON "course" AS PERMISSIVE FOR SELECT TO public USING ((is_published OR is_user_in_course_group_or_admin(group_id)));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "course" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "course" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "course" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete your own comment" ON "course_newsfeed" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = ( SELECT groupmember.profile_id
|
||||
-- FROM groupmember
|
||||
-- WHERE (groupmember.id = course_newsfeed.author_id))));--> statement-breakpoint
|
||||
-- CREATE POLICY "Update only your own" ON "course_newsfeed" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "course_newsfeed" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to SELECT" ON "course_newsfeed" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete your own" ON "course_newsfeed_comment" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = ( SELECT groupmember.profile_id
|
||||
-- FROM groupmember
|
||||
-- WHERE (groupmember.id = course_newsfeed_comment.author_id))));--> statement-breakpoint
|
||||
-- CREATE POLICY "Update only your own" ON "course_newsfeed_comment" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "course_newsfeed_comment" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to SELECT" ON "course_newsfeed_comment" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "exercise" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "exercise" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "exercise" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "exercise" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "group" AS PERMISSIVE FOR INSERT TO "authenticated" WITH CHECK (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "group" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only org admins can delete" ON "group" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only org admins can update" ON "group" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "group_attendance" AS PERMISSIVE FOR INSERT TO public WITH CHECK (is_user_in_course_group_or_admin(( SELECT course.group_id
|
||||
-- FROM course
|
||||
-- WHERE (course.id = group_attendance.course_id)
|
||||
-- LIMIT 1)));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to SELECT" ON "group_attendance" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to UPDATE" ON "group_attendance" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "group_attendance" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "groupmember" AS PERMISSIVE FOR INSERT TO "authenticated" WITH CHECK (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "groupmember" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "groupmember" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "groupmember" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "lesson" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "lesson" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "lesson" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "lesson" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Delete only your own comment" ON "lesson_comment" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() = ( SELECT groupmember.profile_id
|
||||
-- FROM groupmember
|
||||
-- WHERE (groupmember.id = lesson_comment.groupmember_id))));--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "lesson_comment" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Update only your own" ON "lesson_comment" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be in course group to INSERT" ON "lesson_comment" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "lesson_completion" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an course member or Admin to perform ALL operation" ON "lesson_completion" AS PERMISSIVE FOR ALL TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "lesson_completion" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "lesson_completion" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "lesson_language" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "lesson_language" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "lesson_language" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "lesson_language" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "lesson_language_history" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "lesson_language_history" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "lesson_language_history" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "lesson_language_history" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "lesson_section" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "lesson_section" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "lesson_section" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "lesson_section" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Allow authenticated users to SELECT" ON "option" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "option" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "option" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "option" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can delete" ON "organization" AS PERMISSIVE FOR DELETE TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "organization" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "organization" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be admin to UPDATE" ON "organization" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "organization_contacts" AS PERMISSIVE FOR INSERT TO public WITH CHECK (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "organization_emaillist" AS PERMISSIVE FOR INSERT TO public WITH CHECK (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "organization_plan" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "organization_plan" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "organization_plan" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "organization_plan" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Allow authenticated users to read." ON "organizationmember" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable insert for authenticated users only" ON "organizationmember" AS PERMISSIVE FOR INSERT TO "authenticated";--> statement-breakpoint
|
||||
-- CREATE POLICY "Only admin can delete" ON "organizationmember" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only admin can update" ON "organizationmember" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only user can update their account via email" ON "organizationmember" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an admin to INSERT or allow if no existing member" ON "organizationmember" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only auth users can read profile" ON "profile" AS PERMISSIVE FOR SELECT TO "anon", "authenticated" USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Public profiles are viewable by everyone." ON "profile" AS PERMISSIVE FOR SELECT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User can only delete their profiles" ON "profile" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Users can insert their own profile." ON "profile" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Users can update own profile." ON "profile" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Allow authenticated users to SELECT" ON "question" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to DELETE" ON "question" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to INSERT" ON "question" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an org member to UPDATE" ON "question" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Only authenticated users can select." ON "question_answer" AS PERMISSIVE FOR SELECT TO public USING ((auth.uid() IS NOT NULL));--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an course member to DELETE" ON "question_answer" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an course member to INSERT" ON "question_answer" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be an course member to UPDATE" ON "question_answer" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "question_type" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "quiz" AS PERMISSIVE FOR SELECT TO "authenticated" USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "quiz_play" AS PERMISSIVE FOR SELECT TO "authenticated" USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Enable read access for all users" ON "role" AS PERMISSIVE FOR SELECT TO public USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "Only authenticated users can SELECT" ON "submission" AS PERMISSIVE FOR SELECT TO "authenticated" USING (true);--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to DELETE" ON "submission" AS PERMISSIVE FOR DELETE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to INSERT" ON "submission" AS PERMISSIVE FOR INSERT TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "User must be a course member to UPDATE" ON "submission" AS PERMISSIVE FOR UPDATE TO public;--> statement-breakpoint
|
||||
-- CREATE POLICY "Authenticated users can SELECT" ON "submissionstatus" AS PERMISSIVE FOR SELECT TO "authenticated" USING (true);
|
||||
|
||||
select id from public.profile limit 1;
|
||||
@@ -1,47 +0,0 @@
|
||||
CREATE TABLE "public"."account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "public"."session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
"impersonated_by" text,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "public"."user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||
"role" text,
|
||||
"banned" boolean DEFAULT false,
|
||||
"ban_reason" text,
|
||||
"ban_expires" timestamp,
|
||||
"is_anonymous" boolean,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,20 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "public"."verification" (
|
||||
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" DROP CONSTRAINT IF EXISTS "account_user_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" DROP CONSTRAINT IF EXISTS "session_user_id_user_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DEFAULT uuid_generate_v4();--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ALTER COLUMN "user_id" SET DATA TYPE uuid USING user_id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -1,306 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "public"."exercise_template" (
|
||||
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "exercise_template_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"title" text,
|
||||
"description" text,
|
||||
"tag" text,
|
||||
"questionnaire" jsonb DEFAULT '{}'::jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."analytics_login_events" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."apps_poll" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."apps_poll_option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."apps_poll_submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."community_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."community_question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."course" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."course_newsfeed" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."course_newsfeed_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_section" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."course_section" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."exercise" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."group" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."group_attendance" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."groupmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_comment" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_completion" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_language" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_language_history" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."option" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."organization" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."organization_contacts" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."organization_emaillist" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."organization_plan" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."organizationmember" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."profile" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."question" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."question_answer" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."question_type" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."quiz" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."quiz_play" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."role" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."submission" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."submissionstatus" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "public"."lesson" DROP CONSTRAINT IF EXISTS "public_lesson_section_id_fkey";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."lesson_section" DROP CONSTRAINT IF EXISTS "public_lesson_section_course_id_fkey";--> statement-breakpoint
|
||||
DO $migrate_rename_lesson_section_to_course_section$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relkind = 'r' AND c.relname = 'lesson_section'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_class c2
|
||||
JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
|
||||
WHERE n2.nspname = 'public' AND c2.relkind = 'r' AND c2.relname = 'course_section'
|
||||
) THEN
|
||||
ALTER TABLE public.lesson_section RENAME TO course_section;
|
||||
END IF;
|
||||
END;
|
||||
$migrate_rename_lesson_section_to_course_section$;--> statement-breakpoint
|
||||
ALTER TABLE IF EXISTS "public"."course_newsfeed_comment" DROP CONSTRAINT IF EXISTS "course_newsfeed_comment_id_key";--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."account" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."analytics_login_events" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."community_answer" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."course" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."exercise" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."group" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."groupmember" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."lesson" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."option" ALTER COLUMN "value" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."organization" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."question" ALTER COLUMN "name" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."quiz" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."session" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."submission" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."user" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."verification" ALTER COLUMN "id" SET DATA TYPE uuid USING id::uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."verification" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint
|
||||
ALTER TABLE "public"."exercise" ADD COLUMN "course_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."exercise" ADD COLUMN "section_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "public"."exercise" ADD COLUMN "order" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "public"."exercise" ADD COLUMN "is_unlocked" boolean DEFAULT true;--> statement-breakpoint
|
||||
DO $add_exercise_course_fk$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'exercise' AND schemaname = 'public')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint co
|
||||
JOIN pg_class rel ON rel.oid = co.conrelid
|
||||
JOIN pg_namespace n ON n.oid = rel.relnamespace
|
||||
WHERE n.nspname = 'public' AND rel.relname = 'exercise' AND co.contype = 'f' AND co.conname = 'exercise_course_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.exercise ADD CONSTRAINT exercise_course_id_fkey FOREIGN KEY ("course_id") REFERENCES public.course("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END;
|
||||
$add_exercise_course_fk$;--> statement-breakpoint
|
||||
DO $add_exercise_section_fk$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'exercise' AND schemaname = 'public')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint co
|
||||
JOIN pg_class rel ON rel.oid = co.conrelid
|
||||
JOIN pg_namespace n ON n.oid = rel.relnamespace
|
||||
WHERE n.nspname = 'public' AND rel.relname = 'exercise' AND co.contype = 'f' AND co.conname = 'exercise_section_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.exercise ADD CONSTRAINT exercise_section_id_fkey FOREIGN KEY ("section_id") REFERENCES public.course_section("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END;
|
||||
$add_exercise_section_fk$;--> statement-breakpoint
|
||||
DO $add_lesson_course_section_fk$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'lesson' AND schemaname = 'public')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint co
|
||||
JOIN pg_class rel ON rel.oid = co.conrelid
|
||||
JOIN pg_namespace n ON n.oid = rel.relnamespace
|
||||
WHERE n.nspname = 'public' AND rel.relname = 'lesson' AND co.contype = 'f' AND co.conname = 'public_course_section_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.lesson ADD CONSTRAINT public_course_section_id_fkey FOREIGN KEY ("section_id") REFERENCES public.course_section("id") ON DELETE cascade ON UPDATE cascade;
|
||||
END IF;
|
||||
END;
|
||||
$add_lesson_course_section_fk$;--> statement-breakpoint
|
||||
DO $add_course_section_course_fk$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'course_section' AND schemaname = 'public')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint co
|
||||
JOIN pg_class rel ON rel.oid = co.conrelid
|
||||
JOIN pg_namespace n ON n.oid = rel.relnamespace
|
||||
WHERE n.nspname = 'public' AND rel.relname = 'course_section' AND co.contype = 'f' AND co.conname = 'public_course_section_course_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.course_section ADD CONSTRAINT public_course_section_course_id_fkey FOREIGN KEY ("course_id") REFERENCES public.course("id") ON DELETE cascade ON UPDATE cascade;
|
||||
END IF;
|
||||
END;
|
||||
$add_course_section_course_fk$;--> statement-breakpoint
|
||||
ALTER TABLE "public"."course" DROP COLUMN "version";--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable delete for users based on user_id" ON "public"."analytics_login_events" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Users can insert their own login events" ON "public"."analytics_login_events" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete only your own poll" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update only your own" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "public"."apps_poll" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a teacher to DELETE" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be course member to SELECT" ON "public"."apps_poll_option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can read" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete your own submission" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update your own submission" ON "public"."apps_poll_submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."community_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete your own answer" ON "public"."community_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."community_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update your own answer" ON "public"."community_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."community_question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete your own question" ON "public"."community_question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."community_question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update your own question" ON "public"."community_question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable access to all users if PUBLIC or to course members when " ON "public"."course" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."course" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."course" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."course" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete your own comment" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update only your own" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."course_newsfeed" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete your own" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update only your own" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."course_newsfeed_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."exercise" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."exercise" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."exercise" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."exercise" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."group" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."group" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only org admins can delete" ON "public"."group" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only org admins can update" ON "public"."group" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to SELECT" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."group_attendance" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."groupmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."groupmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."groupmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."groupmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Delete only your own comment" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Update only your own" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be in course group to INSERT" ON "public"."lesson_comment" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an course member or Admin to perform ALL operation" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_completion" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_language" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."lesson_language_history" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."course_section" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."course_section" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."course_section" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."course_section" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "public"."option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."option" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can delete" ON "public"."organization" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."organization" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be admin to UPDATE" ON "public"."organization" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_contacts" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_emaillist" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."organization_plan" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to read." ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable insert for authenticated users only" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only admin can delete" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only admin can update" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only user can update their account via email" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an admin to INSERT or allow if no existing member" ON "public"."organizationmember" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only auth users can read profile" ON "public"."profile" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON "public"."profile" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User can only delete their profiles" ON "public"."profile" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Users can insert their own profile." ON "public"."profile" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Users can update own profile." ON "public"."profile" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to SELECT" ON "public"."question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to DELETE" ON "public"."question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to INSERT" ON "public"."question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an org member to UPDATE" ON "public"."question" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only authenticated users can select." ON "public"."question_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an course member to DELETE" ON "public"."question_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an course member to INSERT" ON "public"."question_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be an course member to UPDATE" ON "public"."question_answer" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."question_type" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."quiz" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."quiz_play" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Enable read access for all users" ON "public"."role" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Only authenticated users can SELECT" ON "public"."submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to DELETE" ON "public"."submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to INSERT" ON "public"."submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "User must be a course member to UPDATE" ON "public"."submission" CASCADE;--> statement-breakpoint
|
||||
DROP POLICY IF EXISTS "Authenticated users can SELECT" ON "public"."submissionstatus" CASCADE;--> statement-breakpoint
|
||||
DROP TYPE "public"."COURSE_VERSION";
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.group_attendance', 'id'), COALESCE((SELECT MAX(id) FROM public.group_attendance), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.currency', 'id'), COALESCE((SELECT MAX(id) FROM public.currency), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.apps_poll_option', 'id'), COALESCE((SELECT MAX(id) FROM public.apps_poll_option), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.option', 'id'), COALESCE((SELECT MAX(id) FROM public.option), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.quiz_play', 'id'), COALESCE((SELECT MAX(id) FROM public.quiz_play), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.organization_contacts', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_contacts), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.lesson_comment', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_comment), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.submissionstatus', 'id'), COALESCE((SELECT MAX(id) FROM public.submissionstatus), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.video_transcripts', 'id'), COALESCE((SELECT MAX(id) FROM public.video_transcripts), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.organization_emaillist', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_emaillist), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.apps_poll_submission', 'id'), COALESCE((SELECT MAX(id) FROM public.apps_poll_submission), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.lesson_completion', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_completion), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.community_question', 'id'), COALESCE((SELECT MAX(id) FROM public.community_question), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.course_newsfeed_comment', 'id'), COALESCE((SELECT MAX(id) FROM public.course_newsfeed_comment), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.organization_plan', 'id'), COALESCE((SELECT MAX(id) FROM public.organization_plan), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.lesson_language', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_language), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.lesson_language_history', 'id'), COALESCE((SELECT MAX(id) FROM public.lesson_language_history), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.organizationmember', 'id'), COALESCE((SELECT MAX(id) FROM public.organizationmember), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.question', 'id'), COALESCE((SELECT MAX(id) FROM public.question), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.question_answer', 'id'), COALESCE((SELECT MAX(id) FROM public.question_answer), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.question_type', 'id'), COALESCE((SELECT MAX(id) FROM public.question_type), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.role', 'id'), COALESCE((SELECT MAX(id) FROM public.role), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.waitinglist', 'id'), COALESCE((SELECT MAX(id) FROM public.waitinglist), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.test_tenant', 'id'), COALESCE((SELECT MAX(id) FROM public.test_tenant), 1), true);
|
||||
--> statement-breakpoint
|
||||
SELECT setval(pg_get_serial_sequence('public.exercise_template', 'id'), COALESCE((SELECT MAX(id) FROM public.exercise_template), 1), true);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,29 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1760910273722,
|
||||
"tag": "0000_stale_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1760912065001,
|
||||
"tag": "0001_tranquil_thunderbolts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1762733677656,
|
||||
"tag": "0002_many_revanche",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1769931903954,
|
||||
"tag": "0003_fancy_harry_osborn",
|
||||
"when": 1781617442553,
|
||||
"tag": "0000_shocking_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import postgres, { type Sql } from 'postgres';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
// .../packages/db/src/scripts/baseline.ts → migrations live at ../migrations
|
||||
const migrationsDir = resolve(dirname(scriptPath), '../migrations');
|
||||
|
||||
type JournalEntry = { idx: number; when: number; tag: string };
|
||||
|
||||
/**
|
||||
* Reads the drizzle journal and computes each migration's hash the SAME way drizzle-orm's
|
||||
* `readMigrationFiles` does: sha256 of the raw `<tag>.sql` file contents, paired with the
|
||||
* journal `when` timestamp (drizzle's `created_at`).
|
||||
*/
|
||||
function readJournal(): { tag: string; when: number; hash: string }[] {
|
||||
const journalPath = resolve(migrationsDir, 'meta/_journal.json');
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as { entries: JournalEntry[] };
|
||||
return journal.entries.map((entry) => {
|
||||
const sqlText = fs.readFileSync(resolve(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
return {
|
||||
tag: entry.tag,
|
||||
when: entry.when,
|
||||
hash: crypto.createHash('sha256').update(sqlText).digest('hex')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an existing database into drizzle's migration history so `drizzle-kit migrate` is safe.
|
||||
*
|
||||
* drizzle applies every journal entry whose `when` is greater than the max `created_at` in
|
||||
* `drizzle.__drizzle_migrations`. A database that already has the schema but no journal — created
|
||||
* by the old `drizzle-kit push` or by the prior supabase setup — would make `migrate` re-run the
|
||||
* baseline migration and fail with `type/table "..." already exists`. Seeding the journal marks
|
||||
* those migrations as already applied so only *future* migrations run.
|
||||
*
|
||||
* No-ops when:
|
||||
* - the journal already records the consolidated baseline migration, or
|
||||
* - the `public` schema has no tables (fresh DB — let `migrate` create everything).
|
||||
*
|
||||
* Special case — migration squash: a DB tracked under an older journal (its `__drizzle_migrations`
|
||||
* holds the pre-squash hashes) would otherwise let `migrate` re-run the consolidated baseline,
|
||||
* whose `when` is newer than every old `created_at`, and crash on `CREATE TYPE/TABLE "..." already
|
||||
* exists`. We adopt the new baseline (insert its hash) so only *future* migrations run.
|
||||
*/
|
||||
export async function baselineMigrationsIfNeeded(sql: Sql): Promise<void> {
|
||||
const entries = readJournal();
|
||||
const baseline = entries[0]; // idx 0 = the consolidated baseline migration
|
||||
|
||||
// 1. Already migrate-tracked?
|
||||
const journalTable = await sql<{ count: number }[]>`
|
||||
SELECT count(*)::int AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'drizzle' AND table_name = '__drizzle_migrations'
|
||||
`;
|
||||
if (journalTable[0].count > 0) {
|
||||
const rows = await sql<{ count: number }[]>`SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations`;
|
||||
if (rows[0].count > 0) {
|
||||
// Has history. If the consolidated baseline hash is missing, this DB was tracked under the
|
||||
// pre-squash journal — adopt the new baseline so `migrate` won't re-run it. Only the baseline
|
||||
// entry is reconciled here; genuinely pending future migrations must still run.
|
||||
const hasBaseline = await sql<{ count: number }[]>`
|
||||
SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE hash = ${baseline.hash}
|
||||
`;
|
||||
if (hasBaseline[0].count === 0) {
|
||||
await sql`INSERT INTO drizzle.__drizzle_migrations ("hash", "created_at") VALUES (${baseline.hash}, ${baseline.when})`;
|
||||
console.log(
|
||||
'Pre-squash migration history detected — baselined consolidated migration as already applied.'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fresh database? Let `migrate` create the schema normally.
|
||||
const publicTables = await sql<{ count: number }[]>`
|
||||
SELECT count(*)::int AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
`;
|
||||
if (publicTables[0].count === 0) return;
|
||||
|
||||
// 3. Existing schema, no journal → baseline current migrations as already applied.
|
||||
await sql`CREATE SCHEMA IF NOT EXISTS drizzle`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`;
|
||||
|
||||
let inserted = 0;
|
||||
for (const entry of entries) {
|
||||
const existing = await sql<{ count: number }[]>`
|
||||
SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE hash = ${entry.hash}
|
||||
`;
|
||||
if (existing[0].count === 0) {
|
||||
await sql`INSERT INTO drizzle.__drizzle_migrations ("hash", "created_at") VALUES (${entry.hash}, ${entry.when})`;
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Existing schema detected without migration history — baselined ${inserted} migration(s) as already applied.`
|
||||
);
|
||||
}
|
||||
|
||||
// Allow running standalone: `pnpm --filter @cio/db db:baseline`
|
||||
const invokedDirectly = Boolean(process.argv[1]) && scriptPath === resolve(process.argv[1]);
|
||||
if (invokedDirectly) {
|
||||
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
||||
if (!connectionString) {
|
||||
console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
const sql = postgres(connectionString);
|
||||
baselineMigrationsIfNeeded(sql)
|
||||
.then(() => sql.end())
|
||||
.then(() => process.exit(0))
|
||||
.catch(async (error) => {
|
||||
console.error('Baseline failed:', error);
|
||||
await sql.end();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { baselineMigrationsIfNeeded } from './baseline';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? '';
|
||||
const shouldSeed = process.argv.includes('--seed') || process.argv.includes('-s');
|
||||
const shouldSyncSchema = !process.argv.includes('--skip-schema-sync');
|
||||
@@ -43,9 +45,9 @@ async function runPnpmCommand(commandLabel: string, args: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
async function runSchemaSync() {
|
||||
console.log('Syncing schema with drizzle-kit push...');
|
||||
await runPnpmCommand('Schema sync', ['db', 'push']);
|
||||
async function runMigrations() {
|
||||
console.log('Applying database migrations with drizzle-kit migrate...');
|
||||
await runPnpmCommand('Migrate', ['db:migrate']);
|
||||
}
|
||||
|
||||
async function runSeedEssential() {
|
||||
@@ -88,7 +90,18 @@ async function dbSetup() {
|
||||
// Note: 'public' role exists by default in PostgreSQL
|
||||
|
||||
if (shouldSyncSchema) {
|
||||
await runSchemaSync();
|
||||
// Advisory lock so concurrent startups can't both pass the baseline check and double-insert.
|
||||
// Session-scoped, so held on a dedicated connection across baseline+migrate.
|
||||
const DB_SETUP_LOCK_KEY = 4242424242;
|
||||
const lock = postgres(connectionString, { max: 1 });
|
||||
await lock`SELECT pg_advisory_lock(${DB_SETUP_LOCK_KEY}::bigint)`;
|
||||
try {
|
||||
await baselineMigrationsIfNeeded(sql);
|
||||
await runMigrations();
|
||||
} finally {
|
||||
await lock`SELECT pg_advisory_unlock(${DB_SETUP_LOCK_KEY}::bigint)`;
|
||||
await lock.end();
|
||||
}
|
||||
}
|
||||
|
||||
await runSeedEssential();
|
||||
|
||||
+150
-45
@@ -3,25 +3,40 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker/docker-compose.yaml"
|
||||
BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml"
|
||||
IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml"
|
||||
PROJECT_NAME="classroomio"
|
||||
ENV_FILE="${ROOT_DIR}/.env"
|
||||
BUILD_IMAGES=true
|
||||
# Default to PULLING pre-built images (fast). Pass --build to build from source instead.
|
||||
USE_IMAGES=true
|
||||
BUILD_IMAGES=false
|
||||
COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
|
||||
COMPOSE_PROFILES="--profile minio"
|
||||
|
||||
print_usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: ./run-docker-full-stack.sh [--no-build] [--no-minio]
|
||||
Usage: ./run-docker-full-stack.sh [--build] [--no-build] [--no-minio]
|
||||
|
||||
By default this PULLS the pre-built classroomio/* images from Docker Hub at the tag in
|
||||
CIO_VERSION (.env) — no local build, no full repo needed.
|
||||
|
||||
Options:
|
||||
--no-build Start containers without rebuilding images.
|
||||
--build Build the images from source (docker-compose.yaml) instead of pulling.
|
||||
--no-build With --build, start containers without rebuilding.
|
||||
--no-minio Exclude MinIO (object storage). By default MinIO is included.
|
||||
--images Deprecated alias — pulling images is now the default.
|
||||
-h, --help Show this help message.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--build)
|
||||
USE_IMAGES=false
|
||||
BUILD_IMAGES=true
|
||||
COMPOSE_FILE="${BUILD_COMPOSE_FILE}"
|
||||
shift
|
||||
;;
|
||||
--no-build)
|
||||
BUILD_IMAGES=false
|
||||
shift
|
||||
@@ -30,6 +45,13 @@ while [[ $# -gt 0 ]]; do
|
||||
COMPOSE_PROFILES=""
|
||||
shift
|
||||
;;
|
||||
--images)
|
||||
# Pulling is the default now; kept for backward compatibility.
|
||||
USE_IMAGES=true
|
||||
BUILD_IMAGES=false
|
||||
COMPOSE_FILE="${IMAGES_COMPOSE_FILE}"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
@@ -88,7 +110,7 @@ is_insecure_token_value() {
|
||||
fi
|
||||
|
||||
case "${normalized}" in
|
||||
"" | "replace-with-a-long-random-token" | "replace-with-the-same-value-as-AUTH_BEARER_TOKEN" | "local-dev-api-key" | "changeme" | "replace-me")
|
||||
"" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me")
|
||||
return 0
|
||||
;;
|
||||
replace-with-* | *local-dev* | *change-this* | *your-*)
|
||||
@@ -124,36 +146,75 @@ ensure_secure_auth_tokens() {
|
||||
touch "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
local auth_bearer_token
|
||||
local private_server_key
|
||||
local generated_token
|
||||
|
||||
auth_bearer_token="$(get_env_value AUTH_BEARER_TOKEN)"
|
||||
private_server_key="$(get_env_value PRIVATE_SERVER_KEY)"
|
||||
|
||||
if is_insecure_token_value "${auth_bearer_token}" && is_insecure_token_value "${private_server_key}"; then
|
||||
generated_token="$(generate_secure_token)"
|
||||
upsert_env_value AUTH_BEARER_TOKEN "${generated_token}"
|
||||
upsert_env_value PRIVATE_SERVER_KEY "${generated_token}"
|
||||
echo "Generated secure AUTH_BEARER_TOKEN and PRIVATE_SERVER_KEY in .env"
|
||||
return
|
||||
# PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be
|
||||
# identical in both services. Generate a strong value when it's empty or a known placeholder.
|
||||
if is_insecure_token_value "${private_server_key}"; then
|
||||
upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)"
|
||||
echo "Generated secure PRIVATE_SERVER_KEY in .env"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_secure_betterauth_secret() {
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
touch "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
if is_insecure_token_value "${auth_bearer_token}" && ! is_insecure_token_value "${private_server_key}"; then
|
||||
upsert_env_value AUTH_BEARER_TOKEN "${private_server_key}"
|
||||
echo "Set AUTH_BEARER_TOKEN to match existing PRIVATE_SERVER_KEY in .env"
|
||||
return
|
||||
fi
|
||||
local secret
|
||||
secret="$(get_env_value BETTER_AUTH_SECRET)"
|
||||
|
||||
if ! is_insecure_token_value "${auth_bearer_token}" && is_insecure_token_value "${private_server_key}"; then
|
||||
upsert_env_value PRIVATE_SERVER_KEY "${auth_bearer_token}"
|
||||
echo "Set PRIVATE_SERVER_KEY to match existing AUTH_BEARER_TOKEN in .env"
|
||||
return
|
||||
# Only (re)generate when the value is empty or a known placeholder; never clobber
|
||||
# a strong secret the user has set themselves.
|
||||
if is_insecure_token_value "${secret}"; then
|
||||
upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)"
|
||||
echo "Generated secure BETTER_AUTH_SECRET in .env"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${auth_bearer_token}" != "${private_server_key}" ]]; then
|
||||
upsert_env_value PRIVATE_SERVER_KEY "${auth_bearer_token}"
|
||||
echo "Normalized PRIVATE_SERVER_KEY to match AUTH_BEARER_TOKEN in .env"
|
||||
is_local_origin() {
|
||||
local value="$1"
|
||||
case "${value}" in
|
||||
"" | *localhost* | *127.0.0.1* | *0.0.0.0*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not
|
||||
# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it.
|
||||
# We only override values that are still empty/localhost so explicit S3/R2 config is kept.
|
||||
derive_public_storage_urls() {
|
||||
local dashboard_origin public_endpoint media_base
|
||||
dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)"
|
||||
public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)"
|
||||
media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)"
|
||||
|
||||
if ! is_local_origin "${dashboard_origin}"; then
|
||||
local origin="${dashboard_origin%/}"
|
||||
# Public media is served from the public-download `media` bucket, so deriving its base
|
||||
# URL from the domain is what fixes broken media links in served pages. We deliberately
|
||||
# do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the
|
||||
# dashboard origin would break presigned URLs for the non-public videos/documents buckets.
|
||||
if is_local_origin "${media_base}"; then
|
||||
upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${origin}/media"
|
||||
fi
|
||||
echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${origin}/media)."
|
||||
echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)."
|
||||
echo " -> For presigned access to the videos/documents buckets behind a domain, expose"
|
||||
echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly."
|
||||
else
|
||||
# Local demo: ensure localhost defaults are present.
|
||||
if [[ -z "${public_endpoint}" ]]; then
|
||||
upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
|
||||
fi
|
||||
if [[ -z "${media_base}" ]]; then
|
||||
upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -162,20 +223,29 @@ ensure_minio_env() {
|
||||
touch "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
local endpoint
|
||||
endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
|
||||
local minio_password
|
||||
minio_password="$(get_env_value MINIO_ROOT_PASSWORD)"
|
||||
|
||||
if [[ -z "${endpoint}" || "${endpoint}" =~ ^[[:space:]]*$ ]]; then
|
||||
upsert_env_value MINIO_ROOT_USER "minioadmin"
|
||||
upsert_env_value MINIO_ROOT_PASSWORD "minioadmin"
|
||||
# First provision: randomize credentials whenever the MinIO password is still empty or the
|
||||
# well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on
|
||||
# OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal
|
||||
# copy-the-example first run the randomization was always skipped and minioadmin/minioadmin
|
||||
# survived.) MinIO uses its root credentials as the S3 access key / secret.
|
||||
if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then
|
||||
local minio_user
|
||||
minio_user="cio-$(generate_secure_token | cut -c1-16)"
|
||||
minio_password="$(generate_secure_token)"
|
||||
|
||||
upsert_env_value MINIO_ROOT_USER "${minio_user}"
|
||||
upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}"
|
||||
upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000"
|
||||
upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000"
|
||||
upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "minioadmin"
|
||||
upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "minioadmin"
|
||||
upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}"
|
||||
upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}"
|
||||
upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true"
|
||||
upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media"
|
||||
echo "Set MinIO object storage env vars in .env"
|
||||
echo "Provisioned MinIO with randomized credentials in .env"
|
||||
fi
|
||||
|
||||
derive_public_storage_urls
|
||||
}
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
@@ -184,16 +254,34 @@ if ! command -v docker >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
ensure_secure_auth_tokens
|
||||
ensure_secure_betterauth_secret
|
||||
|
||||
if [[ "${COMPOSE_PROFILES}" == *"minio"* ]]; then
|
||||
ensure_minio_env
|
||||
else
|
||||
# MinIO excluded: the user must supply external object storage, or uploads/media
|
||||
# silently break. Fail fast instead. The endpoint must be set AND not still point at the
|
||||
# bundled MinIO host (which won't be running with --no-minio).
|
||||
external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)"
|
||||
if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then
|
||||
echo "Error: --no-minio was passed but no external object storage is configured."
|
||||
echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)."
|
||||
echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env,"
|
||||
echo "or drop --no-minio to use the bundled MinIO."
|
||||
exit 1
|
||||
fi
|
||||
derive_public_storage_urls
|
||||
fi
|
||||
|
||||
echo "Starting ClassroomIO Docker full stack..."
|
||||
if [[ "${COMPOSE_PROFILES}" != "" ]]; then
|
||||
echo "Including MinIO (object storage, default)..."
|
||||
fi
|
||||
if [[ "${BUILD_IMAGES}" == "true" ]]; then
|
||||
if [[ "${USE_IMAGES}" == "true" ]]; then
|
||||
echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..."
|
||||
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} pull
|
||||
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
|
||||
elif [[ "${BUILD_IMAGES}" == "true" ]]; then
|
||||
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up --build -d
|
||||
else
|
||||
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d
|
||||
@@ -203,15 +291,32 @@ echo
|
||||
echo "Current service status:"
|
||||
docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ps
|
||||
|
||||
# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh
|
||||
# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable
|
||||
# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready
|
||||
# check abort the script (set -euo pipefail is active), so first runs end cleanly.
|
||||
check_endpoint() {
|
||||
local name="$1" url="$2" curl_flags="$3" attempts="$4"
|
||||
local i=0
|
||||
while ((i < attempts)); do
|
||||
if curl ${curl_flags} --max-time 5 "${url}" >/dev/null 2>&1; then
|
||||
echo "${name} is reachable on ${url}"
|
||||
return 0
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 5
|
||||
done
|
||||
echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)."
|
||||
echo " It may still be starting — the first run migrates the database. Follow logs with:"
|
||||
echo " docker compose --env-file \"${ENV_FILE}\" -p \"${PROJECT_NAME}\" -f \"${COMPOSE_FILE}\" logs -f"
|
||||
return 0
|
||||
}
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "Checking API endpoint..."
|
||||
curl -fsS --max-time 10 http://localhost:3081/ >/dev/null
|
||||
echo "API is reachable on http://localhost:3081/"
|
||||
|
||||
echo "Checking dashboard endpoint..."
|
||||
curl -fsSI --max-time 10 http://localhost:3082/ >/dev/null
|
||||
echo "Dashboard is reachable on http://localhost:3082/"
|
||||
echo "Waiting for services to come up (first run can take a minute while the API migrates)..."
|
||||
check_endpoint "API" "http://localhost:3081/" "-fsS" 24
|
||||
check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12
|
||||
else
|
||||
echo
|
||||
echo "curl not found, skipped endpoint checks."
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"pipeline": {
|
||||
"@cio/dashboard#build": {
|
||||
"dependsOn": ["@cio/api#build", "@cio/ui#build", "@cio/db#build", "@cio/analytics#build"],
|
||||
"cache": false
|
||||
"outputs": [".svelte-kit/**", "build/**"]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user