diff --git a/.github/workflows/deploy-supabase.yml b/.github/workflows/deploy-supabase.yml index b7cb187..d5e266d 100644 --- a/.github/workflows/deploy-supabase.yml +++ b/.github/workflows/deploy-supabase.yml @@ -8,6 +8,11 @@ on: required: false type: boolean default: false + deploy_infrastructure: + description: Apply Terraform as part of this manual deployment + required: false + type: boolean + default: false push: branches: - master @@ -17,6 +22,30 @@ on: - '.github/workflows/deploy-supabase.yml' jobs: + changes: + name: Detect infrastructure changes + runs-on: ubuntu-latest + outputs: + infrastructure: ${{ steps.infrastructure.outputs.changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: infrastructure + name: Check Terraform scope + env: + BEFORE_SHA: ${{ github.event.before }} + CURRENT_SHA: ${{ github.sha }} + DEPLOY_INFRASTRUCTURE: ${{ inputs.deploy_infrastructure }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "changed=${DEPLOY_INFRASTRUCTURE:-false}" >> "$GITHUB_OUTPUT" + elif git diff --quiet "$BEFORE_SHA" "$CURRENT_SHA" -- terraform/; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + quality: name: Verify deployable source runs-on: ubuntu-latest @@ -126,9 +155,10 @@ jobs: done deploy-infrastructure: - name: Deploy Infrastructure (Terraform) + name: Deploy Infrastructure (Terraform) runs-on: ubuntu-latest - needs: quality + needs: [quality, changes] + if: needs.changes.outputs.infrastructure == 'true' env: TF_VAR_supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }} diff --git a/apps/admin/Dockerfile b/apps/admin/Dockerfile index f72bd1e..f55b97e 100755 --- a/apps/admin/Dockerfile +++ b/apps/admin/Dockerfile @@ -2,19 +2,12 @@ FROM node:20-bookworm-slim AS builder WORKDIR /app -# Enable pnpm via corepack (allow non-matching packageManager) -ENV COREPACK_ENABLE_STRICT=0 -ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy everything first (workspaces need full structure) +# Copy the npm workspace and install from its committed lockfile. COPY . . - -# Install all dependencies -RUN pnpm install --no-frozen-lockfile +RUN npm ci # Build shared package first -RUN pnpm exec turbo run build --filter=@sauci/shared +RUN npm run build -w @sauci/shared # Build admin app with env vars ARG VITE_SUPABASE_URL @@ -43,7 +36,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL -RUN pnpm exec turbo run build --filter=@sauci/admin +RUN npm run build -w @sauci/admin FROM nginx:alpine COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html diff --git a/apps/admin/Dockerfile.dev b/apps/admin/Dockerfile.dev index 82e6588..948046d 100644 --- a/apps/admin/Dockerfile.dev +++ b/apps/admin/Dockerfile.dev @@ -2,34 +2,15 @@ FROM node:20-bookworm-slim WORKDIR /app -# Enable pnpm via corepack (allow non-matching packageManager) -ENV COREPACK_ENABLE_STRICT=0 -ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -# Copy root package files and workspace configs -COPY package*.json ./ -COPY pnpm-workspace.yaml ./ -COPY turbo.json ./ - -# Copy all workspace package.json files -COPY apps/admin/package.json ./apps/admin/ -COPY apps/mobile/package.json ./apps/mobile/ -COPY apps/supabase/package.json ./apps/supabase/ -COPY apps/web/package.json ./apps/web/ -COPY packages/shared/package.json ./packages/shared/ - -# Install all dependencies at root level (workspaces) -RUN pnpm install --no-frozen-lockfile - -# Copy all source code +# Install the complete npm workspace from its committed lockfile. COPY . . +RUN npm ci # Build shared package -RUN pnpm exec turbo run build --filter=@sauci/shared +RUN npm run build -w @sauci/shared WORKDIR /app/apps/admin EXPOSE 3001 -CMD ["pnpm", "run", "dev"] +CMD ["npm", "run", "dev"] diff --git a/apps/admin/src/components/content/ContentReviewControl.tsx b/apps/admin/src/components/content/ContentReviewControl.tsx new file mode 100644 index 0000000..9278b02 --- /dev/null +++ b/apps/admin/src/components/content/ContentReviewControl.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import type { ContentReviewStatus } from '@sauci/shared'; +import { Archive, CheckCircle2, ClipboardCheck, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAuth } from '@/contexts/AuthContext'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { + CONTENT_STATUS_LABELS, + type ReviewableContentTable, + updateContentReviewStatus, +} from '@/services/contentReviews'; + +interface ContentReviewControlProps { + table: ReviewableContentTable; + entityId: string; + entityLabel: string; + status?: ContentReviewStatus | null; + reason?: string | null; + onChanged: () => void | Promise; + compact?: boolean; +} + +const badgeClasses: Record = { + unreviewed: 'border-amber-500 text-amber-500', + allowed: 'border-green-500 text-green-500', + archived: 'border-slate-500 text-slate-400', +}; + +const statusIcons: Record = { + unreviewed: ClipboardCheck, + allowed: CheckCircle2, + archived: Archive, +}; + +export function ContentReviewControl({ + table, + entityId, + entityLabel, + status, + reason, + onChanged, + compact = false, +}: ContentReviewControlProps) { + const { isSuperAdmin } = useAuth(); + const resolvedStatus = status ?? 'unreviewed'; + const StatusIcon = statusIcons[resolvedStatus]; + const [open, setOpen] = useState(false); + const [nextStatus, setNextStatus] = useState(resolvedStatus); + const [nextReason, setNextReason] = useState(reason ?? ''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setNextStatus(resolvedStatus); + setNextReason(reason ?? ''); + } + }, [open, reason, resolvedStatus]); + + const save = async () => { + if (!nextReason.trim()) { + toast.error('A review reason is required'); + return; + } + + setSaving(true); + try { + await updateContentReviewStatus(table, entityId, nextStatus, nextReason); + toast.success(`${entityLabel} marked ${CONTENT_STATUS_LABELS[nextStatus].toLowerCase()}`); + setOpen(false); + await onChanged(); + } catch (error) { + toast.error('Failed to update catalogue status'); + console.error(error); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + {CONTENT_STATUS_LABELS[resolvedStatus]} + + + {isSuperAdmin && ( + <> + + + + + Review catalogue status + + This records a reversible editorial decision for {entityLabel}. + Current customer apps do not filter this status yet. + + + +
+
+ + +
+ +
+ +