17 Commits

Author SHA1 Message Date
blaisadmin a502966293 Adding educational components 2026-05-21 22:10:51 -04:00
blaisadmin b7186528c5 Updated bird profile view 2026-05-21 21:23:49 -04:00
blaisadmin 49d75f34be Moved edit bird profile to flock page rather than settings 2026-05-21 20:46:34 -04:00
blaisadmin df3fcbf885 automated dev db build 2026-05-21 17:27:57 -04:00
blaisadmin 4715306d14 improved rescue workflow 2026-05-21 13:30:28 -04:00
blaisadmin 62afc94f2f fixed adding new workspace at login 2026-05-21 13:10:34 -04:00
blaisadmin e6211d7f5e adjustments for dev env 2026-05-21 01:17:42 -04:00
blaisadmin cf3cd96384 Added excel import 2026-05-21 00:04:05 -04:00
blaisadmin 38dcb7f49b updated public profile view 2026-05-20 22:14:07 -04:00
blaisadmin 1c0d57299d added qr, cleaned up profile views, and added the critical alerts 2026-05-20 21:54:17 -04:00
blaisadmin f2c506ec16 Updated bird profile view 2026-05-20 17:38:16 -04:00
Corey Blais 7514c7c306 Merge branch 'main' of https://git.blaishome.online/blaisadmin/FlockPal
# Conflicts:
#	backend/src/repositories/birdRepository.ts
2026-05-20 17:15:00 -04:00
Corey Blais 0db90aab45 Fixed delete workflow and added additional profile info 2026-05-20 17:12:15 -04:00
blaisadmin 6dbe51410c additional image changes 2026-05-02 12:11:20 -04:00
blaisadmin ac1afc613f improved image handling 2026-05-02 12:06:41 -04:00
blaisadmin 01541c5f5c Fixing images 2026-05-02 11:50:31 -04:00
blaisadmin fc6d7c2762 Updating migration script 2026-05-02 10:35:11 -04:00
20 changed files with 4208 additions and 411 deletions
+1
View File
@@ -10,6 +10,7 @@ S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_BASE_URL=
S3_KEY_PREFIX=bird-photos
PHOTO_DELIVERY_MODE=proxy
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:5000
VITE_API_BASE_URL=http://localhost:5000/api
+1
View File
@@ -7,3 +7,4 @@ frontend/dist
data/
backups/
.DS_Store
docker-compose.dev.yaml
+10 -1
View File
@@ -124,8 +124,17 @@ Set these when Wasabi image storage is ready:
- `S3_SECRET_ACCESS_KEY=<secret-key>`
- `S3_PUBLIC_BASE_URL=<optional CDN or public bucket base URL; leave blank for private signed URLs>`
- `S3_KEY_PREFIX=bird-photos`
- `PHOTO_DELIVERY_MODE=proxy`
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys and returns short-lived signed URLs for bird photos.
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys. `PHOTO_DELIVERY_MODE=proxy` streams images through the backend after validating the app photo token; `PHOTO_DELIVERY_MODE=redirect` validates the app token and redirects to a short-lived Wasabi signed URL.
Migrate existing Postgres-stored bird photos after deploying S3 image storage:
```bash
docker compose -f docker-compose.prod.yml exec backend npm run migrate:bird-photos-to-s3 -- --apply
```
Run a dry run first by omitting `--apply`. Use `--limit=10` to migrate a small batch, and `--keep-data-url` if you want to leave the original inline image in Postgres during an initial verification pass.
Bucket settings recommendation:
+2
View File
@@ -8,6 +8,8 @@
"worker:dev": "tsx watch src/worker.ts",
"build": "tsc",
"test": "tsx --test src/**/*.test.ts",
"migrate:bird-photos-to-s3": "node dist/scripts/migrateBirdPhotosToS3.js",
"migrate:bird-photos-to-s3:dev": "tsx src/scripts/migrateBirdPhotosToS3.ts",
"start": "node dist/app.js",
"worker": "node dist/worker.js"
},
+449 -28
View File
@@ -44,6 +44,7 @@ import {
deleteMedicationForBird,
deleteVetVisitForBird,
getBirdById,
getBirdByPublicProfileCode,
listBirds,
listDueBirdMilestoneReminders,
listMemorializedBirds,
@@ -60,6 +61,19 @@ import {
updateVetVisitForBird,
} from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
import {
deleteDailyEducation,
deleteEducationQuestion,
createEducationQuestion,
getDailyEducationForDate,
getEducationOptOut,
listDailyEducationForAdmin,
listDailyEducationQuestions,
listEducationQuestionsForAdmin,
updateEducationOptOut,
updateEducationQuestion,
upsertDailyEducation,
} from './repositories/educationRepository.js';
import {
buildBirdPhotoObjectKey,
getImageExtensionFromContentType,
@@ -73,6 +87,7 @@ import {
createWorkspace,
deleteWorkspaceMember,
deleteWorkspaceIfEmpty,
ensureDefaultWorkspaceForUser,
ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
getPlatformAdminSummary,
@@ -96,6 +111,8 @@ import type {
AuthContext,
BillingInterval,
BillingPlan,
DailyEducationRow,
EducationQuestionRow,
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
@@ -134,6 +151,7 @@ const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
if (trustProxy) {
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
@@ -230,10 +248,25 @@ const lostBirdReportSchema = z.object({
message: z.string().trim().max(1000).optional().or(z.literal('')),
});
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
const birdProfileListSchema = z
.string()
.trim()
.max(1000)
.refine(
(value) => value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).length <= 3,
'Use no more than three list items.',
)
.optional()
.or(z.literal(''));
const birdSchema = z.object({
name: z.string().trim().min(1).max(120),
tagId: z.string().trim().max(80).optional().or(z.literal('')),
species: z.string().trim().min(1).max(120),
motivators: birdProfileListSchema,
demotivators: birdProfileListSchema,
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
gender: birdGenderSchema.optional(),
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
gotchaDay: dateStringSchema.optional().or(z.literal('')),
@@ -241,6 +274,7 @@ const birdSchema = z.object({
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
notifyOnDob: z.boolean().optional(),
notifyOnGotchaDay: z.boolean().optional(),
publicProfileEnabled: z.boolean().optional(),
});
const memorializeBirdSchema = z.object({
@@ -304,6 +338,27 @@ const integrationTokenCreateSchema = z.object({
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 trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null;
@@ -319,7 +374,9 @@ const normalizeBandId = (value?: string | null) => {
const normalizeEmail = (value: string) => value.trim().toLowerCase();
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken();
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url');
const createRandomId = () => crypto.randomUUID();
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
@@ -457,13 +514,11 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
const normalizeAdminRescueWorkspace = (
row: WorkspaceRow & {
owner_email: string | null;
bird_count: number;
member_count: number;
},
) => ({
workspace: normalizeWorkspace(row),
ownerEmail: row.owner_email,
birdCount: Number(row.bird_count ?? 0),
memberCount: Number(row.member_count ?? 0),
});
@@ -479,6 +534,85 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
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) => {
if (!row.photo_object_key) {
return '';
}
const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60;
const payload = Buffer.from(
JSON.stringify({
birdId: row.id,
workspaceId: row.workspace_id,
objectKey: row.photo_object_key,
expiresAt,
}),
).toString('base64url');
const signature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url');
return `${payload}.${signature}`;
};
const verifyBirdPhotoAccessToken = (token: string) => {
const [payload, signature] = token.split('.');
if (!payload || !signature) {
return null;
}
const expectedSignature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url');
const signatureBuffer = Buffer.from(signature);
const expectedSignatureBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) {
return null;
}
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
birdId?: unknown;
workspaceId?: unknown;
objectKey?: unknown;
expiresAt?: unknown;
};
if (
typeof parsed.birdId !== 'string' ||
typeof parsed.workspaceId !== 'number' ||
typeof parsed.objectKey !== 'string' ||
typeof parsed.expiresAt !== 'number' ||
parsed.expiresAt < Math.floor(Date.now() / 1000)
) {
return null;
}
return parsed as {
birdId: string;
workspaceId: number;
objectKey: string;
expiresAt: number;
};
};
const getBirdPhotoUrl = (row: BirdRow) => {
if (!row.photo_object_key) {
return row.photo_data_url;
@@ -490,11 +624,9 @@ const getBirdPhotoUrl = (row: BirdRow) => {
return row.photo_data_url;
}
return getSignedS3ObjectUrl({
config: s3Config,
objectKey: row.photo_object_key,
expiresInSeconds: 15 * 60,
});
const photoUrl = new URL(`${backendBaseUrl}/api/birds/${row.id}/photo`);
photoUrl.searchParams.set('token', signBirdPhotoAccessToken(row));
return photoUrl.toString();
};
const normalizeBird = (row: BirdRow) => ({
@@ -503,6 +635,9 @@ const normalizeBird = (row: BirdRow) => ({
name: row.name,
tagId: normalizeBandId(row.tag_id),
species: row.species,
motivators: row.motivators,
demotivators: row.demotivators,
favoriteSnack: row.favorite_snack,
gender: row.gender,
dateOfBirth: row.date_of_birth,
gotchaDay: row.gotcha_day,
@@ -513,6 +648,8 @@ const normalizeBird = (row: BirdRow) => ({
photoUpdatedAt: row.photo_updated_at,
notifyOnDob: row.notify_on_dob,
notifyOnGotchaDay: row.notify_on_gotcha_day,
publicProfileCode: row.public_profile_code ?? null,
publicProfileEnabled: row.public_profile_enabled ?? false,
memorializedAt: row.memorialized_at,
memorializedOn: row.memorialized_on,
memorialNote: row.memorial_note,
@@ -522,6 +659,16 @@ const normalizeBird = (row: BirdRow) => ({
latestRecordedOn: row.latest_recorded_on,
});
const normalizePublicBirdProfile = (row: BirdRow) => ({
id: row.id,
workspaceId: row.workspace_id,
name: row.name,
favoriteSnack: row.favorite_snack,
gender: row.gender,
dateOfBirth: row.date_of_birth,
photoDataUrl: getBirdPhotoUrl(row),
});
const normalizeWeight = (row: WeightRow) => ({
id: row.id,
birdId: row.bird_id,
@@ -856,6 +1003,21 @@ const syncWorkspaceStripeBilling = async (workspaceId: number) => {
);
};
const cancelWorkspaceStripeSubscription = async (workspace: WorkspaceRow) => {
if (workspace.workspace_type === 'rescue' || (!workspace.stripe_subscription_id && !workspace.stripe_customer_id)) {
return null;
}
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
if (!subscription || subscription.status === 'canceled') {
return null;
}
await getStripeClient().subscriptions.cancel(subscription.id);
return subscription.id;
};
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
if (billingPlan === 'rescue_free') {
throw new Error('Rescue flocks do not use Stripe billing.');
@@ -1168,10 +1330,12 @@ const sendRescueStatusNotification = async ({
workspace,
ownerEmail,
event,
note,
}: {
workspace: WorkspaceRow;
ownerEmail: string | null;
event: 'created' | 'converted' | 'status_changed' | 'canceled';
note?: string | null;
}) => {
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
const eventLabel =
@@ -1195,6 +1359,11 @@ const sendRescueStatusNotification = async ({
`Billing email: ${workspace.billing_email ?? 'not set'}`,
`Flock ID: ${workspace.id}`,
];
const escapedNote = note ? escapeHtml(note) : null;
if (note) {
lines.push(`Note: ${note}`);
}
if (!mailTransport) {
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
@@ -1215,6 +1384,7 @@ const sendRescueStatusNotification = async ({
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
<li><strong>Flock ID:</strong> ${workspace.id}</li>
</ul>
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
`,
});
@@ -1269,6 +1439,17 @@ const sendRescueOnboardingWebhook = async ({
}
};
const trySendRescueOnboardingWebhook = async (payload: Parameters<typeof sendRescueOnboardingWebhook>[0]) => {
try {
await sendRescueOnboardingWebhook(payload);
return null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown rescue onboarding webhook error.';
console.error(`Rescue onboarding webhook failed for workspace ${payload.workspaceId}:`, error);
return `The rescue onboarding webhook failed and this rescue requires manual review. ${errorMessage}`;
}
};
const issueMagicLinkInvite = async ({
email,
name,
@@ -1868,6 +2049,28 @@ app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, re
}
});
app.get('/api/public/birds/:publicProfileCode', async (req: Request, res: Response, next: NextFunction) => {
const parsed = publicProfileCodeSchema.safeParse(req.params.publicProfileCode);
if (!parsed.success) {
res.status(404).json({ error: 'Public bird profile not found.' });
return;
}
try {
const bird = await getBirdByPublicProfileCode(parsed.data);
if (!bird) {
res.status(404).json({ error: 'Public bird profile not found.' });
return;
}
res.json({ bird: normalizePublicBirdProfile(bird) });
} catch (error) {
next(error);
}
});
app.get('/api/auth/providers', (_req: Request, res: Response) => {
res.json({
providers: Object.values(oauthProviders).map((provider) => ({
@@ -1941,7 +2144,7 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
}
await claimWorkspaceInvites(user!);
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
const receivingWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
const memberships = await normalizeWorkspaceMembershipList(user!.id);
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
@@ -2149,7 +2352,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
await claimWorkspaceInvites(user!);
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
const activeWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
@@ -2194,6 +2397,149 @@ 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) => {
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
@@ -2432,15 +2778,6 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
res.status(400).json({ error: 'Rescue onboarding details are required.' });
return;
}
await sendRescueOnboardingWebhook({
action: 'created',
workspaceId,
flockName: parsed.data.name,
ownerEmail: req.auth!.user.email,
requestedByUserId: req.auth!.user.id,
rescueOnboarding: parsed.data.rescueOnboarding,
});
}
const workspace = await createWorkspace({
@@ -2454,10 +2791,20 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
});
if (workspace?.workspace_type === 'rescue') {
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
action: 'created',
workspaceId: workspace.id,
flockName: workspace.name,
ownerEmail: req.auth!.user.email,
requestedByUserId: req.auth!.user.id,
rescueOnboarding: parsed.data.rescueOnboarding!,
});
await sendRescueStatusNotification({
workspace,
ownerEmail: req.auth!.user.email,
event: 'created',
note: onboardingWebhookError,
});
}
@@ -2505,15 +2852,6 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
res.status(400).json({ error: 'Rescue onboarding details are required.' });
return;
}
await sendRescueOnboardingWebhook({
action: 'converted',
workspaceId: currentWorkspace.id,
flockName: parsed.data.name,
ownerEmail: req.auth!.user.email,
requestedByUserId: req.auth!.user.id,
rescueOnboarding: parsed.data.rescueOnboarding,
});
}
const workspace = await updateWorkspace({
@@ -2526,10 +2864,20 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
});
if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
action: 'converted',
workspaceId: workspace.id,
flockName: workspace.name,
ownerEmail: req.auth!.user.email,
requestedByUserId: req.auth!.user.id,
rescueOnboarding: parsed.data.rescueOnboarding!,
});
await sendRescueStatusNotification({
workspace,
ownerEmail: req.auth!.user.email,
event: 'converted',
note: onboardingWebhookError,
});
}
@@ -2546,13 +2894,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
return;
}
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
if (!nextWorkspaceId) {
const fallbackWorkspaceId = await getNextWorkspaceId();
const fallbackWorkspace = await createWorkspace({
id: fallbackWorkspaceId,
name: `${req.auth!.user.name}'s Flock`,
name: 'New Flock',
workspaceType: 'standard',
billingEmail: req.auth!.user.email,
billingPlan: 'household_basic',
@@ -2581,6 +2931,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
res.json({
deletedWorkspaceId: req.auth!.workspace.id,
canceledStripeSubscriptionId,
token: req.auth!.token,
session: await buildSessionPayload(updatedAuth),
});
@@ -2677,6 +3028,66 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
}
});
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
try {
const token = typeof req.query.token === 'string' ? req.query.token : '';
const photoAccess = verifyBirdPhotoAccessToken(token);
if (!photoAccess || photoAccess.birdId !== req.params.birdId) {
res.status(403).json({ error: 'Photo link expired or invalid.' });
return;
}
const bird = await getBirdById(photoAccess.birdId, photoAccess.workspaceId);
if (!bird || bird.photo_object_key !== photoAccess.objectKey) {
res.status(404).json({ error: 'Photo not found.' });
return;
}
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
res.status(503).json({ error: 'Image storage is not configured.' });
return;
}
const signedUrl = getSignedS3ObjectUrl({
config: s3Config,
objectKey: bird.photo_object_key,
expiresInSeconds: 5 * 60,
});
res.setHeader('Cache-Control', 'private, max-age=900');
if (photoDeliveryMode === 'redirect') {
res.redirect(302, signedUrl);
return;
}
const imageResponse = await fetch(signedUrl);
if (!imageResponse.ok) {
res.status(imageResponse.status).json({ error: 'Unable to load bird photo.' });
return;
}
const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || 'application/octet-stream';
const contentLength = imageResponse.headers.get('content-length');
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
res.setHeader('Content-Type', contentType);
if (contentLength) {
res.setHeader('Content-Length', contentLength);
}
res.send(imageBuffer);
} catch (error) {
next(error);
}
});
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = birdSchema.safeParse(req.body);
@@ -2701,6 +3112,9 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -2711,6 +3125,8 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
photoUpdatedAt: photoStorage.photoUpdatedAt,
notifyOnDob: parsed.data.notifyOnDob ?? false,
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
publicProfileCode: createPublicProfileCode(),
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
});
uploadedObjectKeyToCleanup = null;
@@ -2838,6 +3254,9 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
name: parsed.data.name,
tagId: normalizeBandId(parsed.data.tagId),
species: parsed.data.species,
motivators: emptyToNull(parsed.data.motivators),
demotivators: emptyToNull(parsed.data.demotivators),
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
gotchaDay: emptyToNull(parsed.data.gotchaDay),
@@ -2848,6 +3267,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
photoUpdatedAt: photoStorage.photoUpdatedAt,
notifyOnDob: parsed.data.notifyOnDob ?? false,
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(),
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
});
if (!bird) {
+54 -4
View File
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces
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
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -61,10 +64,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
WHERE workspace_type = 'rescue'
AND rescue_verification_status = 'not_required';
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
VALUES (1, 'My Flock', 'standard', 'household_basic')
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
@@ -143,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -216,6 +246,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
name VARCHAR(120) NOT NULL,
tag_id VARCHAR(80),
species VARCHAR(120) NOT NULL,
motivators VARCHAR(1000),
demotivators VARCHAR(1000),
favorite_snack VARCHAR(160),
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
date_of_birth DATE,
gotcha_day DATE,
@@ -226,6 +259,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
photo_updated_at TIMESTAMPTZ,
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
public_profile_code VARCHAR(32),
public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
memorialized_at TIMESTAMPTZ,
memorialized_on DATE,
memorial_note VARCHAR(1000),
@@ -235,6 +270,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE birds
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
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 gotcha_day DATE,
@@ -245,6 +283,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32),
ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
@@ -267,6 +307,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
END IF;
END $$;
DELETE FROM workspaces
WHERE id = 1
AND name = 'My Flock'
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id)
AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id);
ALTER TABLE birds
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
@@ -297,6 +343,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ON birds (photo_object_key)
WHERE photo_object_key IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code
ON birds (public_profile_code)
WHERE public_profile_code IS NOT NULL;
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi',
tag_id: 'A-1',
species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favorite_snack: 'Millet',
gender: 'female',
date_of_birth: null,
gotcha_day: null,
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
name: 'Kiwi',
tagId: 'A-1',
species: 'Cockatiel',
motivators: 'Step-up practice',
demotivators: 'Vacuum noise',
favoriteSnack: 'Millet',
gender: 'female',
dateOfBirth: null,
gotchaDay: null,
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
assert.equal(bird?.name, 'Kiwi');
assert.equal(bird?.workspace_id, 10);
assert.equal(bird?.gender, 'female');
assert.equal(bird?.favorite_snack, 'Millet');
});
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
+76 -15
View File
@@ -20,6 +20,9 @@ const birdSelectFields = `
birds.name,
birds.tag_id,
birds.species,
birds.motivators,
birds.demotivators,
birds.favorite_snack,
birds.gender,
birds.date_of_birth::text,
birds.gotcha_day::text,
@@ -30,6 +33,8 @@ const birdSelectFields = `
birds.photo_updated_at,
birds.notify_on_dob,
birds.notify_on_gotcha_day,
birds.public_profile_code,
birds.public_profile_enabled,
birds.memorialized_at,
birds.memorialized_on::text,
birds.memorial_note,
@@ -59,6 +64,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => {
return result.rows[0] ?? null;
};
export const getBirdByPublicProfileCode = async (publicProfileCode: string) => {
const result = await db.query<BirdRow>(
`SELECT
${birdSelectFields}
FROM birds
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 birds.public_profile_code = $1
AND birds.public_profile_enabled = TRUE
AND birds.memorialized_at IS NULL`,
[publicProfileCode],
);
return result.rows[0] ?? null;
};
export const listBirds = async (workspaceId: number) => {
const result = await db.query<BirdRow>(
`SELECT
@@ -258,6 +284,9 @@ export const createBird = async ({
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -268,12 +297,17 @@ export const createBird = async ({
photoUpdatedAt = null,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode = null,
publicProfileEnabled = false,
}: {
birdId?: string;
workspaceId: number;
name: string;
tagId: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -284,17 +318,22 @@ export const createBird = async ({
photoUpdatedAt?: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileCode?: string | null;
publicProfileEnabled?: boolean;
}) => {
const result = await db.query<BirdRow>(
`INSERT INTO birds (id, workspace_id, name, tag_id, species, 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)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
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, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
`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)
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)
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`,
[
birdId ?? null,
workspaceId,
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -305,6 +344,8 @@ export const createBird = async ({
photoUpdatedAt,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
],
);
@@ -317,6 +358,9 @@ export const updateBird = async ({
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -327,12 +371,17 @@ export const updateBird = async ({
photoUpdatedAt = null,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
}: {
birdId: string;
workspaceId: number;
name: string;
tagId: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favoriteSnack: string | null;
gender: BirdGender;
dateOfBirth: string | null;
gotchaDay: string | null;
@@ -343,26 +392,33 @@ export const updateBird = async ({
photoUpdatedAt?: string | null;
notifyOnDob: boolean;
notifyOnGotchaDay: boolean;
publicProfileCode: string | null;
publicProfileEnabled: boolean;
}) => {
const result = await db.query<BirdRow>(
`UPDATE birds
SET name = $2,
tag_id = $3,
species = $4,
gender = $5,
date_of_birth = $6,
gotcha_day = $7,
chart_color = $8,
photo_data_url = $9,
photo_object_key = $10,
photo_content_type = $11,
photo_updated_at = $12,
notify_on_dob = $13,
notify_on_gotcha_day = $14
motivators = $5,
demotivators = $6,
favorite_snack = $7,
gender = $8,
date_of_birth = $9,
gotcha_day = $10,
chart_color = $11,
photo_data_url = $12,
photo_object_key = $13,
photo_content_type = $14,
photo_updated_at = $15,
notify_on_dob = $16,
notify_on_gotcha_day = $17,
public_profile_code = $18,
public_profile_enabled = $19
WHERE id = $1
AND workspace_id = $15
AND workspace_id = $20
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, 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
FROM weight_records
@@ -382,6 +438,9 @@ export const updateBird = async ({
name,
tagId,
species,
motivators,
demotivators,
favoriteSnack,
gender,
dateOfBirth,
gotchaDay,
@@ -392,6 +451,8 @@ export const updateBird = async ({
photoUpdatedAt,
notifyOnDob,
notifyOnGotchaDay,
publicProfileCode,
publicProfileEnabled,
workspaceId,
],
);
@@ -0,0 +1,153 @@
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);
};
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
createWorkspace,
deleteWorkspaceIfEmpty,
ensureDefaultWorkspaceForUser,
ensurePersonalWorkspaceForUser,
findAlternateWorkspaceForUser,
getPlatformAdminSummary,
@@ -34,6 +35,83 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
assert.match(calls[0].text, /FROM workspace_members/);
});
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensurePersonalWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 4);
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
assert.match(calls[2].text, /INSERT INTO workspaces/);
assert.match(calls[3].text, /INSERT INTO workspace_members/);
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
});
test('ensureDefaultWorkspaceForUser reuses an existing rescue workspace without creating a household flock', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [{ workspace_id: 84 }],
});
const workspaceId = await ensureDefaultWorkspaceForUser(user);
assert.equal(workspaceId, 84);
assert.equal(calls.length, 1);
assert.match(calls[0].text, /FROM workspace_members/);
assert.doesNotMatch(calls[0].text, /workspaces\.workspace_type = 'standard'/);
});
test('ensureDefaultWorkspaceForUser creates a household flock when the user has no workspace', async () => {
const { calls } = mockDb(
{
rowCount: 0,
rows: [],
},
{
rowCount: 0,
rows: [],
},
{
rowCount: 1,
rows: [{ next_id: 43 }],
},
{
rowCount: 1,
rows: [],
},
{
rowCount: 1,
rows: [],
},
);
const workspaceId = await ensureDefaultWorkspaceForUser(user);
assert.equal(workspaceId, 43);
assert.equal(calls.length, 5);
assert.match(calls[0].text, /FROM workspace_members/);
assert.match(calls[1].text, /workspaces\.workspace_type = 'standard'/);
assert.match(calls[3].text, /INSERT INTO workspaces/);
});
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
const { calls } = mockDb(
{ rowCount: 1, rows: [] },
+25 -38
View File
@@ -91,40 +91,14 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return Number(existing.rows[0].workspace_id);
}
const unclaimed = await db.query<{ workspace_id: number }>(
`SELECT workspaces.id AS workspace_id
FROM workspaces
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.id = 1
GROUP BY workspaces.id
HAVING COUNT(workspace_members.id) = 0
LIMIT 1`,
const workspaceId = await getNextWorkspaceId();
await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
if (!unclaimed.rowCount) {
await db.query(
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
} else {
await db.query(
`UPDATE workspaces
SET name = $2,
workspace_type = 'standard',
billing_plan = 'household_basic',
billing_interval = 'monthly',
billing_email = $3,
subscription_status = 'none',
rescue_verification_status = 'not_required',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[workspaceId, `${user.name}'s Flock`, user.email],
);
}
await db.query(
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
VALUES ($1, $2, $3, $3, $4, 'owner', CURRENT_TIMESTAMP)
@@ -140,6 +114,24 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
return workspaceId;
};
export const ensureDefaultWorkspaceForUser = async (user: UserRow) => {
const existing = await db.query<{ workspace_id: number }>(
`SELECT workspace_id
FROM workspace_members
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
WHERE workspace_members.user_id = $1
ORDER BY workspaces.created_at ASC
LIMIT 1`,
[user.id],
);
if (existing.rowCount) {
return Number(existing.rows[0].workspace_id);
}
return ensurePersonalWorkspaceForUser(user);
};
export const claimWorkspaceInvites = async (user: UserRow) => {
await db.query(
`UPDATE workspace_members
@@ -388,7 +380,6 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
export const listRescueWorkspacesForAdmin = async () => {
const result = await db.query<
WorkspaceRow & {
owner_email: string | null;
bird_count: number;
member_count: number;
}
@@ -406,17 +397,13 @@ export const listRescueWorkspacesForAdmin = async () => {
workspaces.rescue_verification_status,
workspaces.created_at,
workspaces.updated_at,
owner.invite_email AS owner_email,
COUNT(DISTINCT birds.id)::int AS bird_count,
COUNT(DISTINCT workspace_members.id)::int AS member_count
FROM workspaces
LEFT JOIN workspace_members owner
ON owner.workspace_id = workspaces.id
AND owner.role = 'owner'
LEFT JOIN birds ON birds.workspace_id = workspaces.id
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
WHERE workspaces.workspace_type = 'rescue'
GROUP BY workspaces.id, owner.invite_email
GROUP BY workspaces.id
ORDER BY
CASE workspaces.rescue_verification_status
WHEN 'pending' THEN 0
@@ -0,0 +1,134 @@
import { db } from '../db/client.js';
import { ensureSchema } from '../db/schema.js';
import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { deleteS3Object, putS3Object } from '../storage/s3Client.js';
type BirdPhotoMigrationRow = {
id: string;
workspace_id: number;
name: string;
photo_data_url: string;
};
const parseDataImage = (dataUrl: string) => {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
if (!match) {
return null;
}
return {
contentType: match[1],
content: Buffer.from(match[2], 'base64'),
};
};
const getArgValue = (name: string) => {
const prefix = `${name}=`;
const match = process.argv.find((arg) => arg.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
};
const dryRun = !process.argv.includes('--apply');
const keepDataUrl = process.argv.includes('--keep-data-url');
const limitArg = getArgValue('--limit');
const limit = limitArg ? Number(limitArg) : null;
if (limit !== null && (!Number.isInteger(limit) || limit <= 0)) {
console.error('Invalid --limit value. Use a positive integer.');
process.exit(2);
}
const run = async () => {
await ensureSchema();
const s3Config = getS3ImageStorageConfig();
if (!s3Config) {
throw new Error('S3 image storage is not fully configured. Set IMAGE_STORAGE_PROVIDER=s3 and the S3_* environment variables.');
}
const result = await db.query<BirdPhotoMigrationRow>(
`SELECT id, workspace_id, name, photo_data_url
FROM birds
WHERE photo_object_key IS NULL
AND photo_data_url LIKE 'data:image/%'
ORDER BY created_at ASC
${limit ? 'LIMIT $1' : ''}`,
limit ? [limit] : undefined,
);
if (dryRun) {
console.log(`Dry run: ${result.rows.length} bird photo(s) would be migrated to bucket ${s3Config.bucket}.`);
console.log('Run with --apply to upload objects and update rows.');
return;
}
let migrated = 0;
let skipped = 0;
let failed = 0;
for (const bird of result.rows) {
const parsedImage = parseDataImage(bird.photo_data_url);
if (!parsedImage) {
skipped += 1;
console.warn(`Skipping bird ${bird.id} (${bird.name}): invalid data URL.`);
continue;
}
const objectKey = buildBirdPhotoObjectKey({
workspaceId: bird.workspace_id,
birdId: bird.id,
extension: getImageExtensionFromContentType(parsedImage.contentType),
});
try {
await putS3Object({
config: s3Config,
objectKey,
content: parsedImage.content,
contentType: parsedImage.contentType,
});
const updateResult = await db.query(
`UPDATE birds
SET photo_object_key = $2,
photo_content_type = $3,
photo_updated_at = CURRENT_TIMESTAMP,
photo_data_url = CASE WHEN $4::boolean THEN photo_data_url ELSE NULL END
WHERE id = $1
AND photo_object_key IS NULL
AND photo_data_url LIKE 'data:image/%'`,
[bird.id, objectKey, parsedImage.contentType, keepDataUrl],
);
if (updateResult.rowCount !== 1) {
await deleteS3Object({ config: s3Config, objectKey });
skipped += 1;
console.warn(`Skipped bird ${bird.id} (${bird.name}): row changed before update.`);
continue;
}
migrated += 1;
console.log(`Migrated bird ${bird.id} (${bird.name}) -> ${objectKey}`);
} catch (error) {
failed += 1;
console.error(`Failed to migrate bird ${bird.id} (${bird.name}):`, error);
}
}
console.log(`Migration complete: migrated=${migrated}, skipped=${skipped}, failed=${failed}`);
if (failed > 0) {
process.exitCode = 1;
}
};
run()
.catch((error) => {
console.error('Bird photo migration failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await db.close();
});
+34
View File
@@ -13,9 +13,38 @@ export type UserRow = {
email: string;
password_hash: string | null;
name: string;
education_opt_out?: boolean;
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 = {
id: number;
name: string;
@@ -98,6 +127,9 @@ export type BirdRow = {
name: string;
tag_id: string | null;
species: string;
motivators: string | null;
demotivators: string | null;
favorite_snack: string | null;
gender: BirdGender;
date_of_birth: string | null;
gotcha_day: string | null;
@@ -108,6 +140,8 @@ export type BirdRow = {
photo_updated_at: string | null;
notify_on_dob: boolean;
notify_on_gotcha_day: boolean;
public_profile_code: string | null;
public_profile_enabled: boolean;
memorialized_at: string | null;
memorialized_on: string | null;
memorial_note: string | null;
+2
View File
@@ -51,6 +51,7 @@ services:
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
@@ -127,6 +128,7 @@ services:
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
+2
View File
@@ -49,6 +49,7 @@ services:
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
@@ -120,6 +121,7 @@ services:
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
+497 -1
View File
@@ -8,8 +8,11 @@
"name": "flockpal-frontend",
"version": "0.1.0",
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"read-excel-file": "^9.0.9"
},
"devDependencies": {
"@types/react": "18.3.12",
@@ -1144,6 +1147,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1151,6 +1163,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
@@ -1192,6 +1213,39 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
@@ -1205,6 +1259,12 @@
"node": ">=6.0.0"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -1239,6 +1299,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001786",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
@@ -1260,6 +1329,35 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1267,6 +1365,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1292,6 +1396,30 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
@@ -1299,6 +1427,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1348,6 +1482,39 @@
"node": ">=6"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-extra": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1373,6 +1540,42 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1405,6 +1608,30 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1453,6 +1680,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
@@ -1460,6 +1693,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1467,6 +1745,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -1496,6 +1783,29 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1531,6 +1841,50 @@
"node": ">=0.10.0"
}
},
"node_modules/read-excel-file": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz",
"integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.9",
"fflate": "^0.8.2",
"unzipper": "^0.12.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1576,6 +1930,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -1595,6 +1955,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1605,6 +1971,41 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -1619,6 +2020,34 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1650,6 +2079,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
@@ -1710,12 +2145,73 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}
+4 -1
View File
@@ -9,8 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"qrcode": "^1.5.4",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"read-excel-file": "^9.0.9"
},
"devDependencies": {
"@types/react": "18.3.12",
+2182 -314
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+497 -9
View File
@@ -122,6 +122,70 @@ textarea {
gap: 1.5rem;
}
.top-alert-notification {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(203, 58, 53, 0.26);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 247, 244, 0.98), rgba(255, 238, 231, 0.96));
box-shadow: 0 16px 30px rgba(203, 58, 53, 0.14);
}
.top-alert-notification div {
display: grid;
gap: 0.1rem;
}
.top-alert-notification strong {
color: var(--accent-red);
}
.top-alert-notification span {
color: var(--muted);
}
.notification-bell {
width: 34px;
height: 34px;
border-radius: 50%;
background: rgba(203, 58, 53, 0.12);
border: 1px solid rgba(203, 58, 53, 0.22);
position: relative;
}
.notification-bell::before {
content: "";
position: absolute;
left: 10px;
top: 7px;
width: 12px;
height: 15px;
border: 2px solid var(--accent-red);
border-bottom: 0;
border-radius: 8px 8px 4px 4px;
}
.notification-bell::after {
content: "";
position: absolute;
left: 12px;
top: 22px;
width: 10px;
height: 5px;
border-top: 2px solid var(--accent-red);
border-radius: 50%;
}
.top-alert-actions {
display: flex;
flex-wrap: wrap;
justify-content: end;
gap: 0.6rem;
}
.side-rail {
position: sticky;
top: 2rem;
@@ -155,6 +219,53 @@ textarea {
align-items: stretch;
}
.public-profile-shell {
max-width: 620px;
}
.public-profile-card {
display: grid;
gap: 1.1rem;
justify-items: center;
text-align: center;
}
.public-profile-logo {
width: min(220px, 70%);
height: auto;
filter: drop-shadow(0 10px 18px rgba(86, 63, 34, 0.12));
}
.public-profile-logo-link {
display: inline-flex;
justify-content: center;
width: 100%;
}
.public-profile-photo {
width: min(260px, 100%);
aspect-ratio: 1;
object-fit: cover;
border-radius: 28px;
border: 1px solid rgba(39, 105, 179, 0.16);
background: rgba(255, 255, 255, 0.86);
}
.public-profile-copy {
display: grid;
gap: 1rem;
justify-items: center;
}
.public-profile-copy h1 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin: 0;
font-size: 2rem;
}
.auth-hero-card {
min-height: 280px;
align-items: end;
@@ -509,22 +620,26 @@ textarea {
order: 1;
}
.settings-card-collaborators {
order: 2;
.settings-card-bird-profiles[hidden] {
display: none;
}
.settings-card-separate-flock {
order: 3;
.settings-card-collaborators {
order: 2;
}
.settings-card-automation {
order: 4;
}
.settings-card-transfer {
.settings-card-bird-import {
order: 5;
}
.settings-card-transfer {
order: 6;
}
.settings-card-flock-profile {
order: 1;
}
@@ -567,6 +682,21 @@ textarea {
gap: 1rem;
}
.import-column-guide {
display: grid;
gap: 0.35rem;
padding: 0.8rem 0.9rem;
border-radius: 16px;
background: rgba(255, 254, 250, 0.72);
color: var(--muted);
font-size: 0.92rem;
}
.import-preview-list {
max-height: 320px;
overflow: auto;
}
.settings-danger-card {
border-color: rgba(203, 58, 53, 0.22);
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
@@ -615,12 +745,141 @@ textarea {
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 {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.member-header-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
justify-content: end;
align-items: center;
}
.member-header-actions .bird-alert-stack {
justify-content: end;
}
.billing-inline-action {
display: flex;
align-items: center;
@@ -896,6 +1155,17 @@ textarea {
font-size: 11px;
}
.latest-weight-callout rect {
fill: rgba(255, 253, 249, 0.94);
stroke: rgba(31, 42, 42, 0.16);
}
.latest-weight-callout text {
fill: var(--text);
font-size: 11px;
font-weight: 700;
}
.historical-weight-line,
.historical-weight-dot {
opacity: 0.48;
@@ -941,6 +1211,32 @@ textarea {
border-radius: 24px;
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);
position: relative;
}
.qr-profile-button {
position: absolute;
top: 0.85rem;
right: 0.85rem;
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid rgba(39, 105, 179, 0.18);
border-radius: 14px;
background: rgba(255, 254, 250, 0.9);
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
}
.qr-profile-button:hover {
transform: translateY(-1px);
border-color: rgba(35, 138, 90, 0.34);
}
.qr-profile-button svg {
width: 24px;
height: 24px;
fill: var(--accent-blue);
}
.profile-copy {
@@ -953,8 +1249,7 @@ textarea {
font-size: 1.6rem;
}
.profile-title,
.detail-gender {
.profile-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
@@ -1052,6 +1347,25 @@ textarea {
grid-column: 1 / -1;
}
.settings-inline-header {
display: grid;
gap: 0.15rem;
padding-top: 0.35rem;
}
.settings-inline-header h3 {
margin: 0;
}
.profile-list-fields {
display: grid;
gap: 0.65rem;
}
.profile-list-fields input {
margin-top: 0;
}
.care-form-actions {
align-self: start;
margin-top: 0;
@@ -1120,6 +1434,21 @@ textarea {
font-size: 1.05rem;
}
.billing-contact-email {
min-width: 0;
overflow-wrap: anywhere;
}
.detail-item-list {
margin: 0;
padding-left: 1.15rem;
font-weight: 600;
}
.detail-item-list li + li {
margin-top: 0.2rem;
}
.summary-list {
display: grid;
gap: 0.2rem;
@@ -1364,6 +1693,21 @@ label {
accent-color: var(--accent-green);
}
.toggle-field {
display: flex;
align-items: center;
gap: 0.65rem;
padding-top: 1.75rem;
}
.toggle-field input[type="checkbox"] {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
accent-color: var(--accent-green);
}
.primary-button {
border: 0;
border-radius: 18px;
@@ -1548,12 +1892,85 @@ label {
gap: 1rem;
}
.qr-modal {
width: min(520px, 100%);
}
.qr-print-card {
display: grid;
gap: 0.8rem;
justify-items: center;
text-align: center;
padding: 1rem;
border-radius: 22px;
background: #fffdf9;
}
.qr-code {
width: min(280px, 100%);
height: auto;
image-rendering: pixelated;
}
.qr-bird-mark rect {
fill: rgba(255, 255, 255, 0.96);
}
.qr-print-card h3,
.qr-print-card p {
margin: 0;
}
.qr-print-card p {
max-width: 100%;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 0.9rem;
}
.modal-alert-list {
display: grid;
gap: 0.9rem;
}
@media print {
body {
background: #fff;
}
body::before,
.no-print {
display: none;
}
.app-modal-backdrop {
position: static;
display: block;
padding: 0;
background: #fff;
backdrop-filter: none;
}
.app-modal {
box-shadow: none;
border: 0;
width: 100%;
max-height: none;
overflow: visible;
}
.qr-print-card {
min-height: 100vh;
align-content: center;
}
}
@media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell,
.auth-panel,
.hero-card,
@@ -1570,6 +1987,7 @@ label {
.app-shell {
padding: 1rem;
gap: 0.85rem;
}
.settings-grid {
@@ -1581,11 +1999,81 @@ label {
grid-column: auto;
}
.side-nav {
position: static;
.top-alert-notification {
grid-template-columns: auto minmax(0, 1fr);
}
.top-alert-actions {
grid-column: 1 / -1;
justify-content: start;
}
.side-rail {
position: static;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.55rem;
}
.brand-lockup {
justify-items: start;
padding-left: 0;
}
.side-nav-logo {
width: min(120px, 27vw);
}
.side-nav.panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
align-items: center;
gap: 0.65rem;
padding: 0.65rem;
border-radius: 20px;
}
.page-tabs {
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
gap: 0.4rem;
}
.page-tab {
min-height: 42px;
padding: 0.55rem 0.65rem;
border-radius: 14px;
text-align: center;
font-size: 0.92rem;
font-weight: 700;
}
.side-nav .secondary-button {
min-height: 42px;
padding: 0.55rem 0.75rem;
border-radius: 14px;
white-space: nowrap;
}
.workspace-switcher {
grid-column: 1 / -1;
gap: 0.5rem;
}
.workspace-switcher-list {
display: flex;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.workspace-switcher-item {
min-width: 160px;
padding: 0.55rem 0.7rem;
border-radius: 14px;
}
.workspace-switcher-item small {
display: none;
}
}