Make the production stack deployable and disclose the AI-generated catalogue

This commit is contained in:
Aaron Po
2026-10-03 20:37:22 -04:00
parent bd0e9e4a20
commit 4105914633
7 changed files with 280 additions and 12 deletions

View File

@@ -6,10 +6,10 @@ services:
container_name: prod-env-sqlserver
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express"
volumes:
- sqlserverdata-prod:/var/opt/mssql
- biergartendata-prod:/var/opt/mssql
healthcheck:
test:
[
@@ -46,13 +46,44 @@ services:
networks:
- prodnet
database.seed:
env_file: ".env.prod"
image: database.seed
container_name: prod-env-database-seed
depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- prodnet
api.core:
env_file: ".env.prod"
image: api.core
container_name: prod-env-api-core
depends_on:
sqlserver:
condition: service_healthy
database.seed:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
dockerfile: API/API.Core/Dockerfile
@@ -73,12 +104,32 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped
networks:
- prodnet
volumes:
- nuget-cache-prod:/root/.nuget/packages
seaweedfs:
env_file: ".env.prod"
image: chrislusf/seaweedfs
container_name: prod-env-seaweedfs
# No published ports: the S3 API, filer, master and admin UIs are reachable
# only from other containers on prodnet.
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-prod:/data
restart: unless-stopped
networks:
- prodnet
frontend:
env_file: ".env.prod"
image: frontend
@@ -90,10 +141,10 @@ services:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
- "127.0.0.1:5656:5656"
environment:
NODE_ENV: "production"
PORT: "3000"
PORT: "5656"
API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}"
restart: unless-stopped
@@ -101,8 +152,13 @@ services:
- prodnet
volumes:
sqlserverdata-prod:
driver: local
# Declared external so `docker compose down -v` leaves the data alone: Compose
# removes only the volumes it owns. Create them once with `docker volume
# create` before the first `up`.
biergartendata-prod:
external: true
seaweedfsdata-prod:
external: true
nuget-cache-prod:
driver: local

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import AiDisclosureDialog from './AiDisclosureDialog';
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
const meta = {
title: 'Disclosure/AiDisclosureDialog',
component: AiDisclosureDialog,
tags: ['autodocs'],
args: {
defaultOpen: true,
},
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: aiDisclosureDescription,
},
},
},
} satisfies Meta<typeof AiDisclosureDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await expect(
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
).toBeVisible();
});
await expect(
canvas.getByRole('link', { name: /how this data was generated/i }),
).toBeVisible();
},
};
export const Acknowledged: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};
export const InitiallyDismissed: Story = {
args: {
defaultOpen: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
).toBeNull();
},
};

View File

@@ -0,0 +1,79 @@
import { Sparks } from 'iconoir-react';
import { useEffect, useState } from 'react';
const ethicsDocUrl =
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
if (!open) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open]);
if (!open) {
return null;
}
return (
<div
className="modal modal-open"
role="dialog"
aria-modal="true"
aria-labelledby="ai-disclosure-title"
>
<div className="modal-box max-w-md">
<div className="flex flex-col items-center gap-3 text-center">
<span className="text-primary">
<Sparks className="size-10" aria-hidden="true" />
</span>
<h2 id="ai-disclosure-title" className="text-lg font-bold">
This content is AI-generated
</h2>
<p className="text-sm text-base-content/70">
The Biergarten App is a proof of concept. Every brewery, beer, and user in
this catalogue is fixture data produced by a language model — none of it
describes a real business, person, or beer.
</p>
<div role="alert" className="alert alert-warning alert-soft text-left">
<span className="text-sm">
Brewing details, local-language text, and coordinates are known to
contain fabrications. Treat nothing here as factual.
</span>
</div>
<a
href={ethicsDocUrl}
target="_blank"
rel="noreferrer"
className="link link-primary text-sm"
>
How this data was generated
</a>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-primary w-full"
autoFocus
onClick={() => setOpen(false)}
>
I understand
</button>
</div>
</div>
</div>
);
};
export default AiDisclosureDialog;

View File

@@ -13,6 +13,7 @@ import './app.css';
import Navbar from './components/ui/navbar/Navbar';
import ToastProvider from './components/ui/toast/ToastProvider';
import { getOptionalAuth } from './features/auth/auth.server';
import AiDisclosureDialog from './features/disclosure/components/AiDisclosureDialog';
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
export const links: Route.LinksFunction = () => [
@@ -60,6 +61,7 @@ const App = ({ loaderData }: Route.ComponentProps) => {
<>
<Navbar auth={auth} />
<ToastProvider />
<AiDisclosureDialog />
<Outlet />
</>
);