21 Commits

Author SHA1 Message Date
blaisadmin f2017068d5 Auto deploy production on main merge 2026-05-30 22:47:11 -04:00
blaisadmin c9702495a3 Add flock member notes and audit tabs 2026-05-30 22:46:31 -04:00
blaisadmin e965cb55ef Revert education feature from main 2026-05-30 15:50:23 -04:00
blaisadmin 505a9b8496 Added backend limits 2026-05-30 15:43:38 -04:00
blaisadmin c6dc5b22b8 Merge dev into main 2026-05-30 15:29:48 -04:00
blaisadmin d2763744eb Fixed build test
Deploy / deploy-dev (push) Successful in 2m35s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 15:22:43 -04:00
blaisadmin 841d0a9669 Updated subscriptions
Deploy / deploy-dev (push) Failing after 4s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 15:19:47 -04:00
blaisadmin b4f6193395 add codegraph
Deploy / deploy-dev (push) Successful in 1m23s
Deploy / deploy-prod (push) Has been skipped
2026-05-30 12:28:16 -04:00
blaisadmin 9ee46e53e0 Validate builds before dev deploy
Deploy / deploy-dev (push) Successful in 1m29s
Deploy / deploy-prod (push) Has been skipped
2026-05-24 21:56:56 -04:00
blaisadmin f16e88e2f0 Validate builds before deploy 2026-05-24 21:48:42 -04:00
blaisadmin 016bc187d4 Run deploy compose from host repo paths 2026-05-24 11:33:58 -04:00
blaisadmin 104f01f75d Use local mounts for production deploy workflow 2026-05-23 18:38:49 -04:00
blaisadmin 613b2c941c Use standalone deploy compose files
Deploy / deploy-dev (push) Successful in 10s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:34:06 -04:00
blaisadmin 3ab3f48f19 Use environment compose files in deploy workflow
Deploy / deploy-dev (push) Successful in 1m25s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:31:00 -04:00
blaisadmin d9822e6626 Use host deploy paths for job container mounts
Deploy / deploy-dev (push) Successful in 40s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 18:27:00 -04:00
blaisadmin 9e92e1212a Avoid duplicate Docker socket mount in deploy workflow
Deploy / deploy-prod (push) Has been skipped
Deploy / deploy-dev (push) Failing after 13m0s
2026-05-23 18:00:30 -04:00
blaisadmin 96bc76ef0d Mount deploy paths in Gitea job containers
Deploy / deploy-dev (push) Failing after 1s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 17:57:44 -04:00
blaisadmin fe4e69ceb5 Use local mounts for Gitea deploy workflow
Deploy / deploy-dev (push) Failing after 3s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 17:51:33 -04:00
blaisadmin c09e7f63ce Force deploy workflow to use configured SSH key
Deploy / deploy-prod (push) Has been skipped
Deploy / deploy-dev (push) Failing after 3s
2026-05-23 16:47:42 -04:00
blaisadmin 306e3a8c85 Add Gitea deploy workflow
Deploy / deploy-dev (push) Failing after 1m6s
Deploy / deploy-prod (push) Has been skipped
2026-05-23 16:35:11 -04:00
blaisadmin 568aee3e70 Adding gitea action 2026-05-23 16:32:59 -04:00
14 changed files with 1270 additions and 1293 deletions
+16
View File
@@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
+6 -3
View File
@@ -29,9 +29,12 @@ STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
STRIPE_PRICE_HOUSEHOLD_MACAW= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY=
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY= STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY=
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
+88
View File
@@ -0,0 +1,88 @@
name: Deploy
on:
push:
branches:
- main
- dev
- develop
workflow_dispatch:
jobs:
deploy-dev:
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
runs-on: ubuntu-latest
container:
volumes:
- /docker/FlockPal-dev:/docker/FlockPal-dev
steps:
- name: Update dev checkout
run: |
set -e
cd /docker/FlockPal-dev
git fetch --all --prune
git reset --hard "origin/${{ github.ref_name }}"
git clean -fd
- name: Validate backend
run: |
set -e
cd /docker/FlockPal-dev
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
- name: Validate frontend
run: |
set -e
cd /docker/FlockPal-dev
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
- name: Validate dev compose config
run: |
set -e
cd /docker/FlockPal-dev
docker compose -f docker-compose.dev.yaml config --quiet
- name: Deploy dev
run: |
set -e
cd /docker/FlockPal-dev
docker compose -f docker-compose.dev.yaml up -d --build
deploy-prod:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
runs-on: ubuntu-latest
container:
volumes:
- /docker/FlockPal:/docker/FlockPal
steps:
- name: Update prod checkout
run: |
set -e
cd /docker/FlockPal
git fetch --all --prune
git reset --hard "origin/${{ github.ref_name }}"
git clean -fd
- name: Validate backend
run: |
set -e
cd /docker/FlockPal
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
- name: Validate frontend
run: |
set -e
cd /docker/FlockPal
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
- name: Validate prod compose config
run: |
set -e
cd /docker/FlockPal
docker compose -f docker-compose.prod.yml config --quiet
- name: Deploy prod
run: |
set -e
cd /docker/FlockPal
docker compose -f docker-compose.prod.yml up -d --build
+5 -3
View File
@@ -203,8 +203,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW` - `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY`
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW`
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY`
- `STRIPE_CHECKOUT_SUCCESS_URL` - `STRIPE_CHECKOUT_SUCCESS_URL`
- `STRIPE_CHECKOUT_CANCEL_URL` - `STRIPE_CHECKOUT_CANCEL_URL`
- `STRIPE_PORTAL_RETURN_URL` - `STRIPE_PORTAL_RETURN_URL`
@@ -221,7 +223,7 @@ Recommended defaults:
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes. - Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`. - Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. - Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`. - Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_macaw`.
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later. - After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
For local development with the Stripe CLI: For local development with the Stripe CLI:
+264 -204
View File
@@ -62,18 +62,12 @@ import {
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import { import {
deleteDailyEducation, createAuditLogEntry,
deleteEducationQuestion, createFlockNote,
createEducationQuestion, deleteFlockNote,
getDailyEducationForDate, listAuditLogEntries,
getEducationOptOut, listFlockNotes,
listDailyEducationForAdmin, } from './repositories/auditRepository.js';
listDailyEducationQuestions,
listEducationQuestionsForAdmin,
updateEducationOptOut,
updateEducationQuestion,
upsertDailyEducation,
} from './repositories/educationRepository.js';
import { import {
buildBirdPhotoObjectKey, buildBirdPhotoObjectKey,
getImageExtensionFromContentType, getImageExtensionFromContentType,
@@ -94,6 +88,7 @@ import {
getMembershipForUser, getMembershipForUser,
getNextWorkspaceId, getNextWorkspaceId,
getWorkspaceById, getWorkspaceById,
getWorkspaceBirdCount,
getWorkspaceTotalBirdCount, getWorkspaceTotalBirdCount,
listOwnedWorkspacesByOwnerEmail, listOwnedWorkspacesByOwnerEmail,
listRescueWorkspacesForAdmin, listRescueWorkspacesForAdmin,
@@ -109,13 +104,13 @@ import {
} from './repositories/workspaceRepository.js'; } from './repositories/workspaceRepository.js';
import type { import type {
AuthContext, AuthContext,
AuditLogEntryRow,
BillingInterval, BillingInterval,
BillingPlan, BillingPlan,
DailyEducationRow,
EducationQuestionRow,
BirdGender, BirdGender,
BirdMilestoneReminderCandidateRow, BirdMilestoneReminderCandidateRow,
BirdRow, BirdRow,
FlockNoteRow,
IntegrationTokenRow, IntegrationTokenRow,
LostBirdMatchRow, LostBirdMatchRow,
MedicationRow, MedicationRow,
@@ -199,7 +194,7 @@ const switchWorkspaceSchema = z.object({
const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceTypeSchema = z.enum(['standard', 'rescue']);
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']); const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
const billingIntervalSchema = z.enum(['monthly', 'yearly']); const billingIntervalSchema = z.enum(['monthly', 'yearly']);
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
const birdGenderSchema = z.enum(['unknown', 'male', 'female']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
@@ -332,33 +327,17 @@ const medicationAdministrationSchema = z.object({
notes: z.string().trim().max(500).optional().or(z.literal('')), notes: z.string().trim().max(500).optional().or(z.literal('')),
}); });
const flockNoteSchema = z.object({
birdId: z.string().uuid().optional().nullable().or(z.literal('')),
body: z.string().trim().min(1).max(5000),
});
const integrationTokenCreateSchema = z.object({ const integrationTokenCreateSchema = z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
scope: integrationTokenScopeSchema.default('read_write'), scope: integrationTokenScopeSchema.default('read_write'),
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(), expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
}); });
const educationQuestionSchema = z
.object({
prompt: z.string().trim().min(1).max(500),
options: z.array(z.string().trim().min(1).max(240)).min(2).max(4),
correctAnswerIndex: z.coerce.number().int().min(0).max(3),
explanation: z.string().trim().max(800).optional().or(z.literal('')),
})
.refine((value) => value.correctAnswerIndex < value.options.length, {
message: 'Correct answer must match one of the quiz options.',
path: ['correctAnswerIndex'],
});
const dailyEducationSchema = z.object({
publishDate: dateStringSchema,
fact: z.string().trim().min(1).max(2000),
});
const educationPreferenceSchema = z.object({
educationOptOut: z.boolean(),
});
const emptyToNull = (value?: string) => { const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? ''; const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
@@ -383,12 +362,16 @@ const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').up
const resolveBillingPlan = ( const resolveBillingPlan = (
workspaceType: WorkspaceType, workspaceType: WorkspaceType,
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw', requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw',
) => { ) => {
if (workspaceType === 'rescue') { if (workspaceType === 'rescue') {
return 'rescue_free' as const; return 'rescue_free' as const;
} }
if (requestedPlan === 'household_hyacinth_macaw') {
return 'household_hyacinth_macaw';
}
if (requestedPlan === 'household_macaw') { if (requestedPlan === 'household_macaw') {
return 'household_macaw'; return 'household_macaw';
} }
@@ -440,8 +423,18 @@ const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, '
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '', yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
}, },
household_macaw: { household_macaw: {
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '', monthly:
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '', process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY?.trim() ||
process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY?.trim() ||
'',
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY?.trim() ?? '',
},
household_hyacinth_macaw: {
monthly:
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY?.trim() ||
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW?.trim() ||
'',
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY?.trim() ?? '',
}, },
}; };
const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = { const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
@@ -454,14 +447,19 @@ const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, '
yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'], yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'],
}, },
household_macaw: { household_macaw: {
monthly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_MACAW'], monthly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY'],
yearly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY'], yearly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY'],
},
household_hyacinth_macaw: {
monthly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW'],
yearly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY'],
}, },
}; };
const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = { const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = {
household_basic: 'Conure', household_basic: 'Conure',
household_plus: 'Indian Ringneck', household_plus: 'Indian Ringneck',
household_macaw: 'Macaw', household_macaw: 'African Grey',
household_hyacinth_macaw: 'Hyacinth Macaw',
}; };
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null; const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
const adminEmails = new Set( const adminEmails = new Set(
@@ -534,25 +532,6 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
const normalizeEducationQuestion = (row: EducationQuestionRow) => ({
id: row.id,
prompt: row.prompt,
options: row.options,
correctAnswerIndex: Number(row.correct_answer_index),
explanation: row.explanation ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({
id: row.id,
publishDate: row.publish_date,
fact: row.fact,
quizQuestions: questions.map(normalizeEducationQuestion),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const signBirdPhotoAccessToken = (row: BirdRow) => { const signBirdPhotoAccessToken = (row: BirdRow) => {
if (!row.photo_object_key) { if (!row.photo_object_key) {
return ''; return '';
@@ -711,6 +690,33 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) =>
createdAt: row.created_at, createdAt: row.created_at,
}); });
const normalizeFlockNote = (row: FlockNoteRow) => ({
id: row.id,
workspaceId: row.workspace_id,
birdId: row.bird_id,
birdName: row.bird_name,
title: row.title,
body: row.body,
createdByUserId: row.created_by_user_id,
createdByName: row.created_by_name,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
id: row.id,
workspaceId: row.workspace_id,
userId: row.user_id,
actorName: row.actor_name,
actorEmail: row.actor_email,
action: row.action,
entityType: row.entity_type,
entityId: row.entity_id,
entityName: row.entity_name,
details: row.details,
createdAt: row.created_at,
});
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
@@ -932,6 +938,26 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing'; return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
}; };
const getBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
if (billingPlan === 'rescue_free') {
return null;
}
if (billingPlan === 'household_basic') {
return 4;
}
if (billingPlan === 'household_plus') {
return 10;
}
if (billingPlan === 'household_macaw') {
return 16;
}
return null;
};
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => { const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') { if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
return status; return status;
@@ -1951,6 +1977,29 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
return false; return false;
}; };
const writeAuditLog = async (
auth: AuthContext,
action: string,
entityType: string,
entityId?: string | null,
entityName?: string | null,
details?: Record<string, unknown>,
) => {
try {
await createAuditLogEntry({
workspaceId: auth.workspace.id,
auth,
action,
entityType,
entityId,
entityName,
details,
});
} catch (error) {
console.error('Unable to write audit log entry', error);
}
};
const isBillingOnlyWorkspaceUpdate = ( const isBillingOnlyWorkspaceUpdate = (
workspace: WorkspaceRow, workspace: WorkspaceRow,
payload: z.infer<typeof workspaceSchema>, payload: z.infer<typeof workspaceSchema>,
@@ -2397,149 +2446,6 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require
} }
}); });
app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const education = await listDailyEducationForAdmin();
res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) });
} catch (error) {
next(error);
}
});
app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const questions = await listEducationQuestionsForAdmin();
res.json({ questions: questions.map(normalizeEducationQuestion) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = dailyEducationSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() });
return;
}
try {
const education = await upsertDailyEducation({
publishDate: parsed.data.publishDate,
fact: parsed.data.fact,
createdByUserId: req.auth!.user.id,
});
res.json({ education: normalizeDailyEducation(education) });
} catch (error) {
next(error);
}
});
app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await createEducationQuestion({
question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) },
createdByUserId: req.auth!.user.id,
});
res.status(201).json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await updateEducationQuestion(req.params.questionId, {
...parsed.data,
explanation: emptyToNull(parsed.data.explanation),
});
if (!question) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteEducationQuestion(req.params.questionId);
if (!deleted) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteDailyEducation(req.params.educationId);
if (!deleted) {
res.status(404).json({ error: 'Daily education item not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const educationOptOut = await getEducationOptOut(req.auth!.user.id);
const education = educationOptOut ? null : await getDailyEducationForDate();
const questions = education ? await listDailyEducationQuestions(education.publish_date) : [];
res.json({
educationOptOut,
education: education ? normalizeDailyEducation(education, questions) : null,
});
} catch (error) {
next(error);
}
});
app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationPreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() });
return;
}
try {
const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut);
res.json({ educationOptOut });
} catch (error) {
next(error);
}
});
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
@@ -2727,6 +2633,10 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWrit
expiresAt, expiresAt,
}); });
await writeAuditLog(req.auth!, 'integration_token.created', 'integration_token', integrationToken!.id, integrationToken!.name, {
scope: integrationToken!.scope,
expiresAt: integrationToken!.expires_at,
});
res.status(201).json({ res.status(201).json({
integrationToken: normalizeIntegrationToken(integrationToken!), integrationToken: normalizeIntegrationToken(integrationToken!),
token: rawToken, token: rawToken,
@@ -2881,6 +2791,10 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
}); });
} }
await writeAuditLog(req.auth!, 'workspace.updated', 'workspace', String(workspace!.id), workspace!.name, {
workspaceType: workspace!.workspace_type,
billingPlan: workspace!.billing_plan,
});
res.json({ workspace: normalizeWorkspace(workspace!) }); res.json({ workspace: normalizeWorkspace(workspace!) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -2995,6 +2909,10 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
existingUser, existingUser,
}); });
await writeAuditLog(req.auth!, 'workspace_member.upserted', 'workspace_member', member!.id, member!.name, {
inviteEmail: member!.invite_email,
role: member!.role,
});
res.status(201).json({ member: normalizeWorkspaceMember(member!) }); res.status(201).json({ member: normalizeWorkspaceMember(member!) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3010,12 +2928,80 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
return; return;
} }
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
app.get('/api/notes', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const notes = await listFlockNotes(req.auth!.workspace.id);
res.json({ notes: notes.map(normalizeFlockNote) });
} catch (error) {
next(error);
}
});
app.post('/api/notes', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = flockNoteSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid note payload', details: parsed.error.flatten() });
return;
}
try {
const note = await createFlockNote({
workspaceId: req.auth!.workspace.id,
birdId: emptyToNull(parsed.data.birdId ?? ''),
body: parsed.data.body,
createdByUserId: req.auth!.user.id,
});
if (!note) {
res.status(404).json({ error: 'Flock member not found for this note.' });
return;
}
await writeAuditLog(req.auth!, 'note.created', 'note', note.id, note.bird_name ?? 'Note', {
birdId: note.bird_id,
birdName: note.bird_name,
});
res.status(201).json({ note: normalizeFlockNote(note) });
} catch (error) {
next(error);
}
});
app.delete('/api/notes/:noteId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteFlockNote(req.params.noteId, req.auth!.workspace.id);
if (!deleted) {
res.status(404).json({ error: 'Note not found.' });
return;
}
await writeAuditLog(req.auth!, 'note.deleted', 'note', deleted.id, deleted.title);
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 250);
const entries = await listAuditLogEntries(req.auth!.workspace.id, limit);
res.json({ entries: entries.map(normalizeAuditLogEntry) });
} catch (error) {
next(error);
}
});
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => { app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const [birds, memorializedBirds] = await Promise.all([ const [birds, memorializedBirds] = await Promise.all([
@@ -3099,6 +3085,23 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
let uploadedObjectKeyToCleanup: string | null = null; let uploadedObjectKeyToCleanup: string | null = null;
try { try {
const birdLimit = getBillingPlanBirdLimit(req.auth!.workspace.billing_plan);
if (birdLimit !== null) {
const currentBirdCount = await getWorkspaceBirdCount(req.auth!.workspace.id);
if (currentBirdCount >= birdLimit) {
res.status(409).json({
error: 'This flock has reached the bird limit for the selected plan. Upgrade the flock subscription or memorialize a bird before adding another.',
code: 'billing_plan_bird_limit_reached',
birdLimit,
currentBirdCount,
billingPlan: req.auth!.workspace.billing_plan,
});
return;
}
}
const birdId = crypto.randomUUID(); const birdId = crypto.randomUUID();
const photoStorage = await resolveBirdPhotoStorage({ const photoStorage = await resolveBirdPhotoStorage({
birdId, birdId,
@@ -3130,6 +3133,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
}); });
uploadedObjectKeyToCleanup = null; uploadedObjectKeyToCleanup = null;
await writeAuditLog(req.auth!, 'bird.created', 'bird', bird!.id, bird!.name, {
species: bird!.species,
tagId: bird!.tag_id,
});
res.status(201).json({ bird: normalizeBird(bird!) }); res.status(201).json({ bird: normalizeBird(bird!) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
@@ -3181,6 +3188,9 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
redirectTo: frontendBaseUrl, redirectTo: frontendBaseUrl,
}); });
await writeAuditLog(req.auth!, 'bird.transfer_invited', 'bird', sourceBird.id, sourceBird.name, {
destinationOwnerEmail,
});
res.status(202).json({ res.status(202).json({
ok: true, ok: true,
bird: normalizeBird(sourceBird), bird: normalizeBird(sourceBird),
@@ -3207,6 +3217,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
return; return;
} }
await writeAuditLog(req.auth!, 'bird.transferred', 'bird', bird.id, bird.name, {
destinationOwnerEmail,
destinationWorkspaceId: targetWorkspace.id,
});
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) }); res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -3278,6 +3292,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
uploadedObjectKeyToCleanup = null; uploadedObjectKeyToCleanup = null;
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete); await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, {
previousName: existingBird.name,
species: bird.species,
});
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup); await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
@@ -3311,6 +3329,7 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
return; return;
} }
await writeAuditLog(req.auth!, 'bird.deleted', 'bird', bird.id, bird.name);
res.status(204).send(); res.status(204).send();
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key); await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
} catch (error) { } catch (error) {
@@ -3351,6 +3370,9 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
return; return;
} }
await writeAuditLog(req.auth!, 'bird.memorialized', 'bird', bird.id, bird.name, {
memorializedOn: bird.memorialized_on,
});
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3377,6 +3399,9 @@ app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAcce
return; return;
} }
await writeAuditLog(req.auth!, 'bird.memorial_reminder_updated', 'bird', bird.id, bird.name, {
notifyOnMemorialDay: bird.notify_on_memorial_day,
});
res.json({ bird: normalizeBird(bird) }); res.json({ bird: normalizeBird(bird) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3414,6 +3439,11 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
} }
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes)); const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
await writeAuditLog(req.auth!, 'weight.created', 'weight', weight!.id, bird.name, {
birdId: bird.id,
weightGrams: parsed.data.weightGrams,
recordedOn: parsed.data.recordedOn,
});
res.status(201).json({ weight: normalizeWeight(weight!) }); res.status(201).json({ weight: normalizeWeight(weight!) });
} catch (error) { } catch (error) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
@@ -3462,6 +3492,11 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
); );
await writeAuditLog(req.auth!, 'vet_visit.created', 'vet_visit', vetVisit!.id, bird.name, {
birdId: bird.id,
visitedOn: parsed.data.visitedOn,
reason: parsed.data.reason,
});
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) }); res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3502,6 +3537,11 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces
return; return;
} }
await writeAuditLog(req.auth!, 'vet_visit.updated', 'vet_visit', vetVisit.id, bird.name, {
birdId: bird.id,
visitedOn: parsed.data.visitedOn,
reason: parsed.data.reason,
});
res.json({ vetVisit: normalizeVetVisit(vetVisit) }); res.json({ vetVisit: normalizeVetVisit(vetVisit) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3528,6 +3568,9 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
return; return;
} }
await writeAuditLog(req.auth!, 'vet_visit.deleted', 'vet_visit', req.params.visitId, bird.name, {
birdId: bird.id,
});
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3575,6 +3618,10 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
emptyToNull(parsed.data.notes), emptyToNull(parsed.data.notes),
); );
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
birdId: bird.id,
birdName: bird.name,
});
res.status(201).json({ medication: normalizeMedication(medication!) }); res.status(201).json({ medication: normalizeMedication(medication!) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3619,6 +3666,10 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
return; return;
} }
await writeAuditLog(req.auth!, 'medication.updated', 'medication', medication.id, medication.name, {
birdId: bird.id,
birdName: bird.name,
});
res.json({ medication: normalizeMedication(medication) }); res.json({ medication: normalizeMedication(medication) });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3645,6 +3696,9 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
return; return;
} }
await writeAuditLog(req.auth!, 'medication.deleted', 'medication', req.params.medicationId, bird.name, {
birdId: bird.id,
});
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -3696,6 +3750,12 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
return; return;
} }
await writeAuditLog(req.auth!, 'medication_administration.recorded', 'medication_administration', administration.id, bird.name, {
birdId: bird.id,
medicationId: req.params.medicationId,
administeredOn: parsed.data.administeredOn,
status: parsed.data.status,
});
res.status(201).json({ administration: normalizeMedicationAdministration(administration) }); res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
} catch (error) { } catch (error) {
next(error); next(error);
+38 -34
View File
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS workspaces_id_check; DROP CONSTRAINT IF EXISTS workspaces_id_check;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -142,37 +139,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id); ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS daily_education (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publish_date DATE NOT NULL UNIQUE,
fact TEXT NOT NULL,
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE daily_education
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
ON daily_education (publish_date DESC);
CREATE TABLE IF NOT EXISTS education_question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt VARCHAR(500) NOT NULL,
options JSONB NOT NULL,
correct_answer_index INTEGER NOT NULL,
explanation VARCHAR(800),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
);
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
ON education_question_bank (created_at DESC);
CREATE TABLE IF NOT EXISTS integration_tokens ( CREATE TABLE IF NOT EXISTS integration_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -372,6 +338,44 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON pending_bird_transfers (bird_id) ON pending_bird_transfers (bird_id)
WHERE completed_at IS NULL; WHERE completed_at IS NULL;
CREATE TABLE IF NOT EXISTS flock_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
title VARCHAR(160) NOT NULL,
body TEXT NOT NULL,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated
ON flock_notes (workspace_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated
ON flock_notes (bird_id, updated_at DESC)
WHERE bird_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS audit_log_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_name VARCHAR(160),
actor_email VARCHAR(255),
action VARCHAR(80) NOT NULL,
entity_type VARCHAR(80) NOT NULL,
entity_id VARCHAR(120),
entity_name VARCHAR(255),
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created
ON audit_log_entries (workspace_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity
ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC);
CREATE TABLE IF NOT EXISTS weight_records ( CREATE TABLE IF NOT EXISTS weight_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
+133
View File
@@ -0,0 +1,133 @@
import { db } from '../db/client.js';
import type { AuditLogEntryRow, AuthContext, FlockNoteRow } from '../types.js';
type AuditLogInput = {
workspaceId: number;
auth?: AuthContext;
action: string;
entityType: string;
entityId?: string | null;
entityName?: string | null;
details?: Record<string, unknown>;
};
export const createAuditLogEntry = async ({
workspaceId,
auth,
action,
entityType,
entityId = null,
entityName = null,
details = {},
}: AuditLogInput) => {
const result = await db.query<AuditLogEntryRow>(
`INSERT INTO audit_log_entries (workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at`,
[
workspaceId,
auth?.user.id ?? null,
auth?.user.name ?? null,
auth?.user.email ?? null,
action,
entityType,
entityId,
entityName,
JSON.stringify(details),
],
);
return result.rows[0] ?? null;
};
export const listAuditLogEntries = async (workspaceId: number, limit = 100) => {
const result = await db.query<AuditLogEntryRow>(
`SELECT id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at
FROM audit_log_entries
WHERE workspace_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[workspaceId, limit],
);
return result.rows;
};
export const listFlockNotes = async (workspaceId: number) => {
const result = await db.query<FlockNoteRow>(
`SELECT flock_notes.id,
flock_notes.workspace_id,
flock_notes.bird_id,
birds.name AS bird_name,
flock_notes.title,
flock_notes.body,
flock_notes.created_by_user_id,
users.name AS created_by_name,
flock_notes.created_at,
flock_notes.updated_at
FROM flock_notes
LEFT JOIN birds ON birds.id = flock_notes.bird_id
LEFT JOIN users ON users.id = flock_notes.created_by_user_id
WHERE flock_notes.workspace_id = $1
ORDER BY flock_notes.updated_at DESC`,
[workspaceId],
);
return result.rows;
};
export const createFlockNote = async ({
workspaceId,
birdId,
body,
createdByUserId,
}: {
workspaceId: number;
birdId: string | null;
body: string;
createdByUserId: string | null;
}) => {
const title = body.split(/\s+/).join(' ').slice(0, 160) || 'Note';
const result = await db.query<FlockNoteRow>(
`WITH inserted_note AS (
INSERT INTO flock_notes (workspace_id, bird_id, title, body, created_by_user_id)
SELECT $1, $2, $3, $4, $5
WHERE $2::uuid IS NULL
OR EXISTS (
SELECT 1
FROM birds
WHERE birds.id = $2
AND birds.workspace_id = $1
)
RETURNING id, workspace_id, bird_id, title, body, created_by_user_id, created_at, updated_at
)
SELECT inserted_note.id,
inserted_note.workspace_id,
inserted_note.bird_id,
birds.name AS bird_name,
inserted_note.title,
inserted_note.body,
inserted_note.created_by_user_id,
users.name AS created_by_name,
inserted_note.created_at,
inserted_note.updated_at
FROM inserted_note
LEFT JOIN birds ON birds.id = inserted_note.bird_id
LEFT JOIN users ON users.id = inserted_note.created_by_user_id`,
[workspaceId, birdId, title, body, createdByUserId],
);
return result.rows[0] ?? null;
};
export const deleteFlockNote = async (noteId: string, workspaceId: number) => {
const result = await db.query<{ id: string; title: string }>(
`DELETE FROM flock_notes
WHERE id = $1
AND workspace_id = $2
RETURNING id, title`,
[noteId, workspaceId],
);
return result.rows[0] ?? null;
};
@@ -1,153 +0,0 @@
import { db } from '../db/client.js';
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
export const getEducationOptOut = async (userId: string) => {
const result = await db.query<{ education_opt_out: boolean }>(
`SELECT education_opt_out
FROM users
WHERE id = $1`,
[userId],
);
return result.rows[0]?.education_opt_out ?? false;
};
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
const result = await db.query<{ education_opt_out: boolean }>(
`UPDATE users
SET education_opt_out = $2
WHERE id = $1
RETURNING education_opt_out`,
[userId, educationOptOut],
);
return result.rows[0]?.education_opt_out ?? educationOptOut;
};
export const getDailyEducationForDate = async (publishDate?: string) => {
const result = publishDate
? await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = $1`,
[publishDate],
)
: await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = CURRENT_DATE`,
);
return result.rows[0] ?? null;
};
export const listDailyEducationForAdmin = async () => {
const result = await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
ORDER BY publish_date DESC
LIMIT 120`,
);
return result.rows;
};
export const upsertDailyEducation = async ({
publishDate,
fact,
createdByUserId,
}: {
publishDate: string;
fact: string;
createdByUserId: string;
}) => {
const result = await db.query<DailyEducationRow>(
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (publish_date) DO UPDATE
SET fact = EXCLUDED.fact,
updated_at = CURRENT_TIMESTAMP
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
[publishDate, fact, createdByUserId],
);
return result.rows[0];
};
export const listEducationQuestionsForAdmin = async () => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY updated_at DESC, created_at DESC
LIMIT 400`,
);
return result.rows;
};
export const listDailyEducationQuestions = async (seedDate?: string) => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
LIMIT 4`,
[seedDate ?? null],
);
return result.rows;
};
export const createEducationQuestion = async ({
question,
createdByUserId,
}: {
question: DailyEducationQuestion;
createdByUserId: string;
}) => {
const result = await db.query<EducationQuestionRow>(
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
VALUES ($1, $2::jsonb, $3, $4, $5)
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
);
return result.rows[0];
};
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
const result = await db.query<EducationQuestionRow>(
`UPDATE education_question_bank
SET prompt = $2,
options = $3::jsonb,
correct_answer_index = $4,
explanation = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
);
return result.rows[0] ?? null;
};
export const deleteEducationQuestion = async (questionId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM education_question_bank
WHERE id = $1
RETURNING id`,
[questionId],
);
return Boolean(result.rowCount);
};
export const deleteDailyEducation = async (educationId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM daily_education
WHERE id = $1
RETURNING id`,
[educationId],
);
return Boolean(result.rowCount);
};
+28 -30
View File
@@ -1,6 +1,6 @@
export type WorkspaceType = 'standard' | 'rescue'; export type WorkspaceType = 'standard' | 'rescue';
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
export type BillingInterval = 'monthly' | 'yearly'; export type BillingInterval = 'monthly' | 'yearly';
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
@@ -13,38 +13,9 @@ export type UserRow = {
email: string; email: string;
password_hash: string | null; password_hash: string | null;
name: string; name: string;
education_opt_out?: boolean;
created_at: string; created_at: string;
}; };
export type DailyEducationQuestion = {
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
export type DailyEducationRow = {
id: string;
publish_date: string;
fact: string;
quiz_questions: DailyEducationQuestion[];
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type EducationQuestionRow = {
id: string;
prompt: string;
options: string[];
correct_answer_index: number;
explanation: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceRow = { export type WorkspaceRow = {
id: number; id: number;
name: string; name: string;
@@ -235,6 +206,33 @@ export type MedicationAdministrationRow = {
created_at: string; created_at: string;
}; };
export type FlockNoteRow = {
id: string;
workspace_id: number;
bird_id: string | null;
bird_name: string | null;
title: string;
body: string;
created_by_user_id: string | null;
created_by_name: string | null;
created_at: string;
updated_at: string;
};
export type AuditLogEntryRow = {
id: string;
workspace_id: number;
user_id: string | null;
actor_name: string | null;
actor_email: string | null;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
details: Record<string, unknown>;
created_at: string;
};
export type AuthContext = { export type AuthContext = {
user: UserRow; user: UserRow;
session: AuthSessionRow; session: AuthSessionRow;
+6 -3
View File
@@ -73,9 +73,12 @@ services:
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
+6 -3
View File
@@ -71,9 +71,12 @@ services:
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
+1 -1
View File
@@ -653,7 +653,7 @@ Request body:
Notes: Notes:
- `workspaceType` must be `standard` or `rescue` - `workspaceType` must be `standard` or `rescue`
- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw` - `billingPlan` may be `household_basic`, `household_plus`, `household_macaw`, or `household_hyacinth_macaw`
- rescue workspaces are forced to `rescue_free` - rescue workspaces are forced to `rescue_free`
Response `201`: Response `201`:
+404 -600
View File
File diff suppressed because it is too large Load Diff
+142 -126
View File
@@ -713,6 +713,11 @@ textarea {
gap: 1.5rem; gap: 1.5rem;
} }
.bird-detail-panel {
margin-right: 3rem;
position: relative;
}
.flock-detail-column { .flock-detail-column {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
@@ -745,123 +750,6 @@ textarea {
align-content: start; align-content: start;
} }
.daily-education-panel,
.daily-quiz,
.quiz-options,
.education-question-editor {
display: grid;
gap: 1rem;
}
.daily-education-panel.condensed {
gap: 0.35rem;
padding-block: 1rem;
}
.daily-education-panel.condensed .panel-header {
margin-bottom: 0;
}
.daily-education-teaser {
margin: 0;
}
.daily-fact {
margin: 0;
padding: 1rem;
border-left: 4px solid var(--accent-gold);
border-radius: 0 8px 8px 0;
background: rgba(255, 254, 250, 0.7);
font-size: 1.08rem;
}
.daily-quiz {
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
}
.quiz-question,
.quiz-editor-question {
min-width: 0;
margin: 0;
border: 1px solid var(--button-border);
}
.quiz-question {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 8px;
background: rgba(255, 254, 250, 0.64);
}
.quiz-question legend,
.quiz-editor-question legend {
padding: 0 0.35rem;
font-weight: 700;
}
.quiz-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.65rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
}
.quiz-option.correct {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(223, 247, 229, 0.82);
}
.quiz-option.incorrect {
border-color: rgba(203, 58, 53, 0.36);
background: rgba(255, 236, 232, 0.82);
}
.quiz-option input {
width: auto;
margin: 0.25rem 0 0;
}
.quiz-feedback {
margin: 0;
color: var(--accent-red);
}
.quiz-feedback.correct {
color: var(--accent-green);
}
.admin-education-panel,
.education-admin-basics,
.quiz-editor-question,
.education-admin-list {
display: grid;
gap: 1rem;
}
.education-admin-basics {
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
}
.quiz-editor-question {
padding: 1rem;
}
.quiz-editor-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.education-admin-list span {
overflow-wrap: anywhere;
}
.button-row { .button-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -1196,6 +1084,35 @@ textarea {
gap: 0.25rem; gap: 0.25rem;
} }
.note-card,
.audit-log-card {
align-items: start;
}
.note-card div,
.audit-log-card div {
display: flex;
gap: 0.75rem;
justify-content: space-between;
align-items: start;
}
.note-card span,
.audit-log-card span,
.audit-log-card small {
color: var(--muted);
}
.note-card p {
margin: 0.35rem 0 0;
white-space: pre-wrap;
}
.note-card .button-row {
justify-content: space-between;
align-items: center;
}
.legend-grid, .legend-grid,
.detail-grid, .detail-grid,
.summary-grid { .summary-grid {
@@ -1207,17 +1124,22 @@ textarea {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem 6.4rem 1rem 1rem;
border-radius: 24px; border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84)); background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
border: 1px solid rgba(39, 105, 179, 0.1); border: 1px solid rgba(39, 105, 179, 0.1);
position: relative; position: relative;
} }
.qr-profile-button { .profile-actions {
position: absolute; position: absolute;
top: 0.85rem; top: 0.85rem;
right: 0.85rem; right: 0.85rem;
display: flex;
gap: 0.45rem;
}
.profile-icon-button {
display: grid; display: grid;
place-items: center; place-items: center;
width: 42px; width: 42px;
@@ -1228,15 +1150,91 @@ textarea {
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12); box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
} }
.qr-profile-button:hover { .profile-icon-button:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34); border-color: rgba(35, 138, 90, 0.34);
} }
.qr-profile-button svg { .profile-icon-button svg {
width: 24px; width: 24px;
height: 24px; height: 24px;
fill: none;
stroke: var(--accent-blue);
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.qr-profile-button svg {
fill: var(--accent-blue); fill: var(--accent-blue);
stroke: none;
}
.bird-detail-tabs {
position: absolute;
top: 5rem;
right: -3rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.bird-detail-tab {
display: grid;
place-items: center;
width: 48px;
height: 44px;
border: 1px solid rgba(39, 105, 179, 0.14);
border-left: 0;
border-radius: 0 14px 14px 0;
background: rgba(255, 254, 250, 0.92);
color: var(--muted);
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.bird-detail-tab svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.bird-detail-tab .weight-tab-icon {
width: 24px;
height: 24px;
fill: currentColor;
stroke: none;
}
.bird-detail-tab .info-tab-icon,
.bird-detail-tab .note-tab-icon,
.bird-detail-tab .audit-tab-icon,
.bird-detail-tab .vet-tab-icon {
width: 24px;
height: 24px;
fill: currentColor;
stroke: none;
}
.bird-detail-tab:hover {
border-color: rgba(35, 138, 90, 0.28);
color: var(--ink);
transform: translateY(-1px);
}
.bird-detail-tab.active {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(240, 248, 244, 0.95);
color: var(--accent-green);
box-shadow: 10px 10px 20px rgba(39, 105, 179, 0.1);
}
.bird-detail-tab-panel {
display: grid;
gap: 1rem;
} }
.profile-copy { .profile-copy {
@@ -1966,11 +1964,6 @@ label {
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell, .app-shell,
.auth-panel, .auth-panel,
.hero-card, .hero-card,
@@ -2008,6 +2001,29 @@ label {
justify-content: start; justify-content: start;
} }
.bird-detail-panel {
margin-right: 0;
}
.profile-hero {
padding: 1rem;
}
.bird-detail-tabs {
position: static;
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
transform: none;
}
.bird-detail-tab {
width: auto;
min-width: 0;
border-left: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 14px;
}
.side-rail { .side-rail {
position: static; position: static;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);