Compare commits
15 Commits
dev
...
59c6b19ad6
| Author | SHA1 | Date | |
|---|---|---|---|
| 59c6b19ad6 | |||
| aa1a4cf6ff | |||
| 5f0fad3cbb | |||
| 545fae59b2 | |||
| d748d2db21 | |||
| 095c91e56d | |||
| f2017068d5 | |||
| c9702495a3 | |||
| e965cb55ef | |||
| 505a9b8496 | |||
| c6dc5b22b8 | |||
| f16e88e2f0 | |||
| 016bc187d4 | |||
| 104f01f75d | |||
| 568aee3e70 |
@@ -3,13 +3,14 @@ name: Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- main
|
||||||
- dev
|
- dev
|
||||||
- develop
|
- develop
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
if: ${{ github.event_name == 'push' }}
|
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +49,7 @@ jobs:
|
|||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
docker compose -f docker-compose.dev.yaml up -d --build
|
||||||
|
|
||||||
deploy-prod:
|
deploy-prod:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
+397
-222
@@ -35,6 +35,7 @@ import {
|
|||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
createBirdMilestoneReminderDelivery,
|
createBirdMilestoneReminderDelivery,
|
||||||
|
createBirdTransferCode,
|
||||||
createMedicationForBird,
|
createMedicationForBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
findBirdsByBandId,
|
findBirdsByBandId,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
deleteVetVisitForBird,
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
getBirdByPublicProfileCode,
|
getBirdByPublicProfileCode,
|
||||||
|
getOpenBirdTransferCode,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
@@ -53,6 +55,7 @@ import {
|
|||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
memorializeBird,
|
memorializeBird,
|
||||||
|
markBirdTransferCodeCompleted,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
updateMemorialReminderPreference,
|
updateMemorialReminderPreference,
|
||||||
@@ -62,18 +65,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 +91,7 @@ import {
|
|||||||
getMembershipForUser,
|
getMembershipForUser,
|
||||||
getNextWorkspaceId,
|
getNextWorkspaceId,
|
||||||
getWorkspaceById,
|
getWorkspaceById,
|
||||||
|
getWorkspaceBirdCount,
|
||||||
getWorkspaceTotalBirdCount,
|
getWorkspaceTotalBirdCount,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
listRescueWorkspacesForAdmin,
|
listRescueWorkspacesForAdmin,
|
||||||
@@ -109,13 +107,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,
|
||||||
@@ -249,6 +247,7 @@ const lostBirdReportSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
|
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
|
||||||
|
const birdTransferCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{12,32}$/);
|
||||||
const birdProfileListSchema = z
|
const birdProfileListSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -267,6 +266,10 @@ const birdSchema = z.object({
|
|||||||
motivators: birdProfileListSchema,
|
motivators: birdProfileListSchema,
|
||||||
demotivators: birdProfileListSchema,
|
demotivators: birdProfileListSchema,
|
||||||
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
vetClinicName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
|
vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')),
|
||||||
|
vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')),
|
||||||
|
vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
gender: birdGenderSchema.optional(),
|
gender: birdGenderSchema.optional(),
|
||||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||||
@@ -332,33 +335,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;
|
||||||
@@ -553,25 +540,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 '';
|
||||||
@@ -657,6 +625,10 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
motivators: row.motivators,
|
motivators: row.motivators,
|
||||||
demotivators: row.demotivators,
|
demotivators: row.demotivators,
|
||||||
favoriteSnack: row.favorite_snack,
|
favoriteSnack: row.favorite_snack,
|
||||||
|
vetClinicName: row.vet_clinic_name,
|
||||||
|
vetClinicAddress: row.vet_clinic_address,
|
||||||
|
vetAccountNumber: row.vet_account_number,
|
||||||
|
vetDoctorName: row.vet_doctor_name,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
gotchaDay: row.gotcha_day,
|
gotchaDay: row.gotcha_day,
|
||||||
@@ -678,6 +650,8 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
latestRecordedOn: row.latest_recorded_on,
|
latestRecordedOn: row.latest_recorded_on,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createBirdTransferCodeValue = () => crypto.randomBytes(12).toString('base64url');
|
||||||
|
|
||||||
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
@@ -730,6 +704,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,
|
||||||
@@ -951,6 +952,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;
|
||||||
@@ -1970,6 +1991,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>,
|
||||||
@@ -2416,149 +2460,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);
|
||||||
|
|
||||||
@@ -2746,7 +2647,11 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWrit
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
await writeAuditLog(req.auth!, 'integration_token.created', 'integration_token', integrationToken!.id, integrationToken!.name, {
|
||||||
|
scope: integrationToken!.scope,
|
||||||
|
expiresAt: integrationToken!.expires_at,
|
||||||
|
});
|
||||||
|
res.status(201).json({
|
||||||
integrationToken: normalizeIntegrationToken(integrationToken!),
|
integrationToken: normalizeIntegrationToken(integrationToken!),
|
||||||
token: rawToken,
|
token: rawToken,
|
||||||
});
|
});
|
||||||
@@ -2900,7 +2805,11 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ workspace: normalizeWorkspace(workspace!) });
|
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!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3014,7 +2923,11 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
|||||||
existingUser,
|
existingUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({ member: normalizeWorkspaceMember(member!) });
|
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!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3029,12 +2942,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();
|
||||||
|
} catch (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();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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([
|
||||||
@@ -3118,6 +3099,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,
|
||||||
@@ -3134,6 +3132,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
|
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
@@ -3148,13 +3150,17 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadedObjectKeyToCleanup = null;
|
uploadedObjectKeyToCleanup = null;
|
||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
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!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3200,7 +3206,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
redirectTo: frontendBaseUrl,
|
redirectTo: frontendBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(202).json({
|
await writeAuditLog(req.auth!, 'bird.transfer_invited', 'bird', sourceBird.id, sourceBird.name, {
|
||||||
|
destinationOwnerEmail,
|
||||||
|
});
|
||||||
|
res.status(202).json({
|
||||||
ok: true,
|
ok: true,
|
||||||
bird: normalizeBird(sourceBird),
|
bird: normalizeBird(sourceBird),
|
||||||
destinationOwnerEmail,
|
destinationOwnerEmail,
|
||||||
@@ -3226,10 +3235,14 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
await writeAuditLog(req.auth!, 'bird.transferred', 'bird', bird.id, bird.name, {
|
||||||
|
destinationOwnerEmail,
|
||||||
|
destinationWorkspaceId: targetWorkspace.id,
|
||||||
|
});
|
||||||
|
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') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3237,6 +3250,118 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/birds/:birdId/transfer-code',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireSessionAuth,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const sourceBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!sourceBird) {
|
||||||
|
res.status(404).json({ error: 'Bird not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ensureBirdWritable(sourceBird, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transferCode = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
transferCode = await createBirdTransferCode({
|
||||||
|
code: createBirdTransferCodeValue(),
|
||||||
|
birdId: sourceBird.id,
|
||||||
|
sourceWorkspaceId: req.auth!.workspace.id,
|
||||||
|
requestedByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505' && attempt < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transferCode) {
|
||||||
|
throw new Error('Unable to create bird transfer code.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(req.auth!, 'bird.transfer_code_created', 'bird', sourceBird.id, sourceBird.name, {
|
||||||
|
transferCodeId: transferCode.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
transferCode: {
|
||||||
|
code: transferCode.code,
|
||||||
|
bird: normalizeBird(sourceBird),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/bird-transfer-codes/:code/accept',
|
||||||
|
requireAuth,
|
||||||
|
requireWriteAccess,
|
||||||
|
requireSessionAuth,
|
||||||
|
requireWorkspaceRole(['owner', 'assistant']),
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = birdTransferCodeSchema.safeParse(req.params.code);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(404).json({ error: 'Bird transfer code not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transferCode = await getOpenBirdTransferCode(parsed.data);
|
||||||
|
|
||||||
|
if (!transferCode) {
|
||||||
|
res.status(404).json({ error: 'Bird transfer code not found or already used.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferCode.source_workspace_id === req.auth!.workspace.id) {
|
||||||
|
res.status(409).json({ error: 'This bird is already in your active flock.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bird = await transferBirdToWorkspace(transferCode.id, transferCode.source_workspace_id, req.auth!.workspace.id);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Bird is no longer available for transfer.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await markBirdTransferCodeCompleted(transferCode.transfer_code_id, req.auth!.workspace.id);
|
||||||
|
await writeAuditLog(req.auth!, 'bird.transfer_code_accepted', 'bird', bird.id, bird.name, {
|
||||||
|
sourceWorkspaceId: transferCode.source_workspace_id,
|
||||||
|
sourceWorkspaceName: transferCode.workspace_name,
|
||||||
|
transferCodeId: transferCode.transfer_code_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ bird: normalizeBird(bird), sourceWorkspaceName: transferCode.workspace_name, workspace: normalizeWorkspace(req.auth!.workspace) });
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
|
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
@@ -3276,6 +3401,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
motivators: emptyToNull(parsed.data.motivators),
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
demotivators: emptyToNull(parsed.data.demotivators),
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
|
vetClinicName: emptyToNull(parsed.data.vetClinicName),
|
||||||
|
vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress),
|
||||||
|
vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber),
|
||||||
|
vetDoctorName: emptyToNull(parsed.data.vetDoctorName),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
@@ -3295,14 +3424,18 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadedObjectKeyToCleanup = null;
|
uploadedObjectKeyToCleanup = null;
|
||||||
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||||
res.json({ bird: normalizeBird(bird) });
|
await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, {
|
||||||
|
previousName: existingBird.name,
|
||||||
|
species: bird.species,
|
||||||
|
});
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in FlockPal.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3330,7 +3463,8 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(204).send();
|
await writeAuditLog(req.auth!, 'bird.deleted', 'bird', bird.id, bird.name);
|
||||||
|
res.status(204).send();
|
||||||
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -3370,7 +3504,10 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ bird: normalizeBird(bird) });
|
await writeAuditLog(req.auth!, 'bird.memorialized', 'bird', bird.id, bird.name, {
|
||||||
|
memorializedOn: bird.memorialized_on,
|
||||||
|
});
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3396,7 +3533,10 @@ app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAcce
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ bird: normalizeBird(bird) });
|
await writeAuditLog(req.auth!, 'bird.memorial_reminder_updated', 'bird', bird.id, bird.name, {
|
||||||
|
notifyOnMemorialDay: bird.notify_on_memorial_day,
|
||||||
|
});
|
||||||
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3432,8 +3572,13 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
res.status(201).json({ weight: normalizeWeight(weight!) });
|
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!) });
|
||||||
} 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') {
|
||||||
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
||||||
@@ -3481,7 +3626,12 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
|
|||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
|
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!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3521,7 +3671,12 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
|
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) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3547,7 +3702,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(204).send();
|
await writeAuditLog(req.auth!, 'vet_visit.deleted', 'vet_visit', req.params.visitId, bird.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
});
|
||||||
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3594,7 +3752,11 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
|||||||
emptyToNull(parsed.data.notes),
|
emptyToNull(parsed.data.notes),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({ medication: normalizeMedication(medication!) });
|
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
birdName: bird.name,
|
||||||
|
});
|
||||||
|
res.status(201).json({ medication: normalizeMedication(medication!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3638,7 +3800,11 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ medication: normalizeMedication(medication) });
|
await writeAuditLog(req.auth!, 'medication.updated', 'medication', medication.id, medication.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
birdName: bird.name,
|
||||||
|
});
|
||||||
|
res.json({ medication: normalizeMedication(medication) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3664,7 +3830,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(204).send();
|
await writeAuditLog(req.auth!, 'medication.deleted', 'medication', req.params.medicationId, bird.name, {
|
||||||
|
birdId: bird.id,
|
||||||
|
});
|
||||||
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -3715,7 +3884,13 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
|
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) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-42
@@ -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,
|
||||||
@@ -249,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
motivators VARCHAR(1000),
|
motivators VARCHAR(1000),
|
||||||
demotivators VARCHAR(1000),
|
demotivators VARCHAR(1000),
|
||||||
favorite_snack VARCHAR(160),
|
favorite_snack VARCHAR(160),
|
||||||
|
vet_clinic_name VARCHAR(160),
|
||||||
|
vet_clinic_address VARCHAR(500),
|
||||||
|
vet_account_number VARCHAR(120),
|
||||||
|
vet_doctor_name VARCHAR(160),
|
||||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
gotcha_day DATE,
|
gotcha_day DATE,
|
||||||
@@ -273,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
||||||
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
||||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||||
@@ -318,8 +292,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
DROP INDEX IF EXISTS idx_birds_workspace_tag_id;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_workspace_tag_id
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_global_tag_id
|
||||||
ON birds (workspace_id, LOWER(tag_id))
|
ON birds (LOWER(BTRIM(tag_id)))
|
||||||
WHERE tag_id IS NOT NULL
|
WHERE tag_id IS NOT NULL
|
||||||
AND BTRIM(tag_id) <> ''
|
AND BTRIM(tag_id) <> ''
|
||||||
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none');
|
||||||
@@ -347,8 +321,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON birds (public_profile_code)
|
ON birds (public_profile_code)
|
||||||
WHERE public_profile_code IS NOT NULL;
|
WHERE public_profile_code IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||||
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,
|
||||||
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
destination_owner_email VARCHAR(255) NOT NULL,
|
destination_owner_email VARCHAR(255) NOT NULL,
|
||||||
@@ -368,11 +342,71 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
||||||
WHERE completed_at IS NULL;
|
WHERE completed_at IS NULL;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||||
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 weight_records (
|
CREATE TABLE IF NOT EXISTS bird_transfer_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
requested_by_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
completed_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_open_bird
|
||||||
|
ON bird_transfer_codes (bird_id, created_at DESC)
|
||||||
|
WHERE completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_transfer_codes_code_open
|
||||||
|
ON bird_transfer_codes (code)
|
||||||
|
WHERE completed_at IS NULL
|
||||||
|
AND revoked_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 (
|
||||||
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,
|
||||||
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
BirdMilestoneReminderDeliveryRow,
|
BirdMilestoneReminderDeliveryRow,
|
||||||
BirdMilestoneReminderType,
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
|
BirdTransferCodeRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
MedicationDoseScheduleItem,
|
MedicationDoseScheduleItem,
|
||||||
@@ -23,6 +24,10 @@ const birdSelectFields = `
|
|||||||
birds.motivators,
|
birds.motivators,
|
||||||
birds.demotivators,
|
birds.demotivators,
|
||||||
birds.favorite_snack,
|
birds.favorite_snack,
|
||||||
|
birds.vet_clinic_name,
|
||||||
|
birds.vet_clinic_address,
|
||||||
|
birds.vet_account_number,
|
||||||
|
birds.vet_doctor_name,
|
||||||
birds.gender,
|
birds.gender,
|
||||||
birds.date_of_birth::text,
|
birds.date_of_birth::text,
|
||||||
birds.gotcha_day::text,
|
birds.gotcha_day::text,
|
||||||
@@ -287,6 +292,10 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName = null,
|
||||||
|
vetClinicAddress = null,
|
||||||
|
vetAccountNumber = null,
|
||||||
|
vetDoctorName = null,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -308,6 +317,10 @@ export const createBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
vetClinicName?: string | null;
|
||||||
|
vetClinicAddress?: string | null;
|
||||||
|
vetAccountNumber?: string | null;
|
||||||
|
vetDoctorName?: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -322,9 +335,9 @@ export const createBird = async ({
|
|||||||
publicProfileEnabled?: boolean;
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[
|
[
|
||||||
birdId ?? null,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -334,6 +347,10 @@ export const createBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -361,6 +378,10 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -382,6 +403,10 @@ export const updateBird = async ({
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favoriteSnack: string | null;
|
favoriteSnack: string | null;
|
||||||
|
vetClinicName: string | null;
|
||||||
|
vetClinicAddress: string | null;
|
||||||
|
vetAccountNumber: string | null;
|
||||||
|
vetDoctorName: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -403,22 +428,26 @@ export const updateBird = async ({
|
|||||||
motivators = $5,
|
motivators = $5,
|
||||||
demotivators = $6,
|
demotivators = $6,
|
||||||
favorite_snack = $7,
|
favorite_snack = $7,
|
||||||
gender = $8,
|
vet_clinic_name = $8,
|
||||||
date_of_birth = $9,
|
vet_clinic_address = $9,
|
||||||
gotcha_day = $10,
|
vet_account_number = $10,
|
||||||
chart_color = $11,
|
vet_doctor_name = $11,
|
||||||
photo_data_url = $12,
|
gender = $12,
|
||||||
photo_object_key = $13,
|
date_of_birth = $13,
|
||||||
photo_content_type = $14,
|
gotcha_day = $14,
|
||||||
photo_updated_at = $15,
|
chart_color = $15,
|
||||||
notify_on_dob = $16,
|
photo_data_url = $16,
|
||||||
notify_on_gotcha_day = $17,
|
photo_object_key = $17,
|
||||||
public_profile_code = $18,
|
photo_content_type = $18,
|
||||||
public_profile_enabled = $19
|
photo_updated_at = $19,
|
||||||
|
notify_on_dob = $20,
|
||||||
|
notify_on_gotcha_day = $21,
|
||||||
|
public_profile_code = $22,
|
||||||
|
public_profile_enabled = $23
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $20
|
AND workspace_id = $24
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -441,6 +470,10 @@ export const updateBird = async ({
|
|||||||
motivators,
|
motivators,
|
||||||
demotivators,
|
demotivators,
|
||||||
favoriteSnack,
|
favoriteSnack,
|
||||||
|
vetClinicName,
|
||||||
|
vetClinicAddress,
|
||||||
|
vetAccountNumber,
|
||||||
|
vetDoctorName,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -482,7 +515,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -518,7 +551,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
AND memorialized_at IS NOT NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -558,7 +591,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -659,7 +692,7 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
failed += 1;
|
failed += 1;
|
||||||
const message =
|
const message =
|
||||||
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
typeof error === 'object' && error && 'code' in error && error.code === '23505'
|
||||||
? 'The receiving flock already has a bird using the same band/tag ID.'
|
? 'That band/tag ID is already in use in FlockPal.'
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: 'Unable to complete pending bird transfer.';
|
: 'Unable to complete pending bird transfer.';
|
||||||
@@ -670,6 +703,93 @@ export const completePendingBirdTransfersForOwner = async (ownerEmail: string, t
|
|||||||
return { completed, failed };
|
return { completed, failed };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createBirdTransferCode = async ({
|
||||||
|
code,
|
||||||
|
birdId,
|
||||||
|
sourceWorkspaceId,
|
||||||
|
requestedByUserId,
|
||||||
|
}: {
|
||||||
|
code: string;
|
||||||
|
birdId: string;
|
||||||
|
sourceWorkspaceId: number;
|
||||||
|
requestedByUserId: string;
|
||||||
|
}) => {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE bird_transfer_codes
|
||||||
|
SET revoked_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE bird_id = $1
|
||||||
|
AND source_workspace_id = $2
|
||||||
|
AND completed_at IS NULL
|
||||||
|
AND revoked_at IS NULL`,
|
||||||
|
[birdId, sourceWorkspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await db.query<BirdTransferCodeRow>(
|
||||||
|
`INSERT INTO bird_transfer_codes (code, bird_id, source_workspace_id, requested_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, code, bird_id, source_workspace_id, requested_by_user_id, completed_at::text, completed_workspace_id, revoked_at::text, created_at`,
|
||||||
|
[code, birdId, sourceWorkspaceId, requestedByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOpenBirdTransferCode = async (code: string) => {
|
||||||
|
const result = await db.query<
|
||||||
|
BirdRow & {
|
||||||
|
transfer_code_id: string;
|
||||||
|
code: string;
|
||||||
|
source_workspace_id: number;
|
||||||
|
requested_by_user_id: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
completed_workspace_id: number | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
transfer_code_created_at: string;
|
||||||
|
workspace_name: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
`SELECT
|
||||||
|
bird_transfer_codes.id AS transfer_code_id,
|
||||||
|
bird_transfer_codes.code,
|
||||||
|
bird_transfer_codes.source_workspace_id,
|
||||||
|
bird_transfer_codes.requested_by_user_id,
|
||||||
|
bird_transfer_codes.completed_at::text,
|
||||||
|
bird_transfer_codes.completed_workspace_id,
|
||||||
|
bird_transfer_codes.revoked_at::text,
|
||||||
|
bird_transfer_codes.created_at AS transfer_code_created_at,
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
${birdSelectFields}
|
||||||
|
FROM bird_transfer_codes
|
||||||
|
INNER JOIN birds ON birds.id = bird_transfer_codes.bird_id
|
||||||
|
INNER JOIN workspaces ON workspaces.id = bird_transfer_codes.source_workspace_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE bird_transfer_codes.code = $1
|
||||||
|
AND bird_transfer_codes.completed_at IS NULL
|
||||||
|
AND bird_transfer_codes.revoked_at IS NULL
|
||||||
|
AND birds.workspace_id = bird_transfer_codes.source_workspace_id
|
||||||
|
AND birds.memorialized_at IS NULL`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markBirdTransferCodeCompleted = async (codeId: string, completedWorkspaceId: number) => {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE bird_transfer_codes
|
||||||
|
SET completed_at = CURRENT_TIMESTAMP,
|
||||||
|
completed_workspace_id = $2
|
||||||
|
WHERE id = $1`,
|
||||||
|
[codeId, completedWorkspaceId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
export const listWeightsForBird = async (birdId: string, workspaceId: number, days: number) => {
|
||||||
const result = await db.query<WeightRow>(
|
const result = await db.query<WeightRow>(
|
||||||
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
`SELECT id, bird_id, weight_grams, recorded_on::text, notes
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
+43
-29
@@ -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;
|
||||||
@@ -130,6 +101,10 @@ export type BirdRow = {
|
|||||||
motivators: string | null;
|
motivators: string | null;
|
||||||
demotivators: string | null;
|
demotivators: string | null;
|
||||||
favorite_snack: string | null;
|
favorite_snack: string | null;
|
||||||
|
vet_clinic_name: string | null;
|
||||||
|
vet_clinic_address: string | null;
|
||||||
|
vet_account_number: string | null;
|
||||||
|
vet_doctor_name: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
gotcha_day: string | null;
|
gotcha_day: string | null;
|
||||||
@@ -187,6 +162,18 @@ export type PendingBirdTransferRow = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BirdTransferCodeRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
bird_id: string;
|
||||||
|
source_workspace_id: number;
|
||||||
|
requested_by_user_id: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
completed_workspace_id: number | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WeightRow = {
|
export type WeightRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
@@ -235,6 +222,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;
|
||||||
|
|||||||
+43
-1
@@ -208,6 +208,10 @@ Role requirements are called out per endpoint below. If the signed-in member lac
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
|
"vetClinicName": "Avian Care Center",
|
||||||
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
|
"vetAccountNumber": "FP-1001",
|
||||||
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
@@ -793,6 +797,10 @@ Request body:
|
|||||||
"name": "Kiwi",
|
"name": "Kiwi",
|
||||||
"tagId": "FP-001",
|
"tagId": "FP-001",
|
||||||
"species": "Cockatiel",
|
"species": "Cockatiel",
|
||||||
|
"vetClinicName": "Avian Care Center",
|
||||||
|
"vetClinicAddress": "123 Feather Lane, Raleigh, NC",
|
||||||
|
"vetAccountNumber": "FP-1001",
|
||||||
|
"vetDoctorName": "Dr. Rivera",
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"dateOfBirth": "2023-05-10",
|
"dateOfBirth": "2023-05-10",
|
||||||
"gotchaDay": "2023-08-21",
|
"gotchaDay": "2023-08-21",
|
||||||
@@ -805,7 +813,7 @@ Request body:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings
|
- `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings
|
||||||
- `chartColor` defaults to `#cb3a35`
|
- `chartColor` defaults to `#cb3a35`
|
||||||
|
|
||||||
Response `201`:
|
Response `201`:
|
||||||
@@ -889,6 +897,40 @@ Possible errors:
|
|||||||
- `409` if that owner email owns more than one receiving flock
|
- `409` if that owner email owns more than one receiving flock
|
||||||
- `409` if the destination flock already has a bird using the same `tagId`
|
- `409` if the destination flock already has a bird using the same `tagId`
|
||||||
|
|
||||||
|
#### `POST /api/birds/:birdId/transfer-code`
|
||||||
|
|
||||||
|
Requires a browser session, write access, and role `owner` or `assistant`. Creates a unique transfer code for a bird. Creating a new open code for the same bird revokes earlier unused codes for that bird.
|
||||||
|
|
||||||
|
Response `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transferCode": {
|
||||||
|
"code": "secure-code",
|
||||||
|
"bird": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/bird-transfer-codes/:code/accept`
|
||||||
|
|
||||||
|
Requires a browser session, write access, and role `owner` or `assistant`. Accepts a transfer code into the signed-in user's active flock.
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bird": {},
|
||||||
|
"sourceWorkspaceName": "Previous Flock",
|
||||||
|
"workspace": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Possible errors:
|
||||||
|
|
||||||
|
- `404` if the code does not exist, was revoked, was already used, or the bird is no longer available
|
||||||
|
- `409` if the bird is already in the active flock or the active flock already has the same `tagId`
|
||||||
|
|
||||||
#### `DELETE /api/birds/:birdId`
|
#### `DELETE /api/birds/:birdId`
|
||||||
|
|
||||||
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a bird.
|
||||||
|
|||||||
+1288
-748
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
+181
-126
@@ -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,92 @@ 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 .report-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 {
|
||||||
@@ -1708,6 +1707,44 @@ label {
|
|||||||
accent-color: var(--accent-green);
|
accent-color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: min(100%, 420px);
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid rgba(53, 129, 98, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.62);
|
||||||
|
box-shadow: 0 12px 24px rgba(86, 63, 34, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -1966,11 +2003,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 +2040,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user