Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a502966293 | |||
| b7186528c5 | |||
| 49d75f34be | |||
| df3fcbf885 | |||
| 4715306d14 | |||
| 62afc94f2f | |||
| e6211d7f5e | |||
| cf3cd96384 | |||
| 38dcb7f49b | |||
| 1c0d57299d | |||
| f2c506ec16 | |||
| 7514c7c306 | |||
| 0db90aab45 |
@@ -7,3 +7,4 @@ frontend/dist
|
|||||||
data/
|
data/
|
||||||
backups/
|
backups/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
docker-compose.dev.yaml
|
||||||
+324
-23
@@ -44,6 +44,7 @@ import {
|
|||||||
deleteMedicationForBird,
|
deleteMedicationForBird,
|
||||||
deleteVetVisitForBird,
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
|
getBirdByPublicProfileCode,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
@@ -60,6 +61,19 @@ import {
|
|||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} 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 {
|
||||||
|
deleteDailyEducation,
|
||||||
|
deleteEducationQuestion,
|
||||||
|
createEducationQuestion,
|
||||||
|
getDailyEducationForDate,
|
||||||
|
getEducationOptOut,
|
||||||
|
listDailyEducationForAdmin,
|
||||||
|
listDailyEducationQuestions,
|
||||||
|
listEducationQuestionsForAdmin,
|
||||||
|
updateEducationOptOut,
|
||||||
|
updateEducationQuestion,
|
||||||
|
upsertDailyEducation,
|
||||||
|
} from './repositories/educationRepository.js';
|
||||||
import {
|
import {
|
||||||
buildBirdPhotoObjectKey,
|
buildBirdPhotoObjectKey,
|
||||||
getImageExtensionFromContentType,
|
getImageExtensionFromContentType,
|
||||||
@@ -73,6 +87,7 @@ import {
|
|||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
findAlternateWorkspaceForUser,
|
findAlternateWorkspaceForUser,
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
@@ -96,6 +111,8 @@ import type {
|
|||||||
AuthContext,
|
AuthContext,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
|
DailyEducationRow,
|
||||||
|
EducationQuestionRow,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
@@ -231,10 +248,25 @@ const lostBirdReportSchema = z.object({
|
|||||||
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
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({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
species: z.string().trim().min(1).max(120),
|
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(),
|
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('')),
|
||||||
@@ -242,6 +274,7 @@ const birdSchema = z.object({
|
|||||||
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
|
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
|
||||||
notifyOnDob: z.boolean().optional(),
|
notifyOnDob: z.boolean().optional(),
|
||||||
notifyOnGotchaDay: z.boolean().optional(),
|
notifyOnGotchaDay: z.boolean().optional(),
|
||||||
|
publicProfileEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const memorializeBirdSchema = z.object({
|
const memorializeBirdSchema = z.object({
|
||||||
@@ -305,6 +338,27 @@ const integrationTokenCreateSchema = z.object({
|
|||||||
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;
|
||||||
@@ -322,6 +376,7 @@ const hashToken = (token: string) => crypto.createHash('sha256').update(token).d
|
|||||||
const createSessionToken = () => crypto.randomBytes(32).toString('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 photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken();
|
||||||
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
||||||
|
const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url');
|
||||||
const createRandomId = () => crypto.randomUUID();
|
const createRandomId = () => crypto.randomUUID();
|
||||||
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
||||||
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||||
@@ -459,13 +514,11 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|||||||
|
|
||||||
const normalizeAdminRescueWorkspace = (
|
const normalizeAdminRescueWorkspace = (
|
||||||
row: WorkspaceRow & {
|
row: WorkspaceRow & {
|
||||||
owner_email: string | null;
|
|
||||||
bird_count: number;
|
bird_count: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
},
|
},
|
||||||
) => ({
|
) => ({
|
||||||
workspace: normalizeWorkspace(row),
|
workspace: normalizeWorkspace(row),
|
||||||
ownerEmail: row.owner_email,
|
|
||||||
birdCount: Number(row.bird_count ?? 0),
|
birdCount: Number(row.bird_count ?? 0),
|
||||||
memberCount: Number(row.member_count ?? 0),
|
memberCount: Number(row.member_count ?? 0),
|
||||||
});
|
});
|
||||||
@@ -481,6 +534,25 @@ 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 '';
|
||||||
@@ -563,6 +635,9 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
tagId: normalizeBandId(row.tag_id),
|
tagId: normalizeBandId(row.tag_id),
|
||||||
species: row.species,
|
species: row.species,
|
||||||
|
motivators: row.motivators,
|
||||||
|
demotivators: row.demotivators,
|
||||||
|
favoriteSnack: row.favorite_snack,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
gotchaDay: row.gotcha_day,
|
gotchaDay: row.gotcha_day,
|
||||||
@@ -573,6 +648,8 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
photoUpdatedAt: row.photo_updated_at,
|
photoUpdatedAt: row.photo_updated_at,
|
||||||
notifyOnDob: row.notify_on_dob,
|
notifyOnDob: row.notify_on_dob,
|
||||||
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
||||||
|
publicProfileCode: row.public_profile_code ?? null,
|
||||||
|
publicProfileEnabled: row.public_profile_enabled ?? false,
|
||||||
memorializedAt: row.memorialized_at,
|
memorializedAt: row.memorialized_at,
|
||||||
memorializedOn: row.memorialized_on,
|
memorializedOn: row.memorialized_on,
|
||||||
memorialNote: row.memorial_note,
|
memorialNote: row.memorial_note,
|
||||||
@@ -582,6 +659,16 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
latestRecordedOn: row.latest_recorded_on,
|
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) => ({
|
const normalizeWeight = (row: WeightRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
birdId: row.bird_id,
|
birdId: row.bird_id,
|
||||||
@@ -916,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) => {
|
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
||||||
if (billingPlan === 'rescue_free') {
|
if (billingPlan === 'rescue_free') {
|
||||||
throw new Error('Rescue flocks do not use Stripe billing.');
|
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||||
@@ -1228,10 +1330,12 @@ const sendRescueStatusNotification = async ({
|
|||||||
workspace,
|
workspace,
|
||||||
ownerEmail,
|
ownerEmail,
|
||||||
event,
|
event,
|
||||||
|
note,
|
||||||
}: {
|
}: {
|
||||||
workspace: WorkspaceRow;
|
workspace: WorkspaceRow;
|
||||||
ownerEmail: string | null;
|
ownerEmail: string | null;
|
||||||
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
||||||
|
note?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
||||||
const eventLabel =
|
const eventLabel =
|
||||||
@@ -1255,6 +1359,11 @@ const sendRescueStatusNotification = async ({
|
|||||||
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
||||||
`Flock ID: ${workspace.id}`,
|
`Flock ID: ${workspace.id}`,
|
||||||
];
|
];
|
||||||
|
const escapedNote = note ? escapeHtml(note) : null;
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
lines.push(`Note: ${note}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mailTransport) {
|
if (!mailTransport) {
|
||||||
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
||||||
@@ -1275,6 +1384,7 @@ const sendRescueStatusNotification = async ({
|
|||||||
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
|
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
|
||||||
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1329,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 ({
|
const issueMagicLinkInvite = async ({
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
@@ -1928,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) => {
|
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
providers: Object.values(oauthProviders).map((provider) => ({
|
providers: Object.values(oauthProviders).map((provider) => ({
|
||||||
@@ -2001,7 +2144,7 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
|
|||||||
}
|
}
|
||||||
|
|
||||||
await claimWorkspaceInvites(user!);
|
await claimWorkspaceInvites(user!);
|
||||||
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
const receivingWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||||
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
||||||
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
||||||
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
||||||
@@ -2209,7 +2352,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
|
|||||||
|
|
||||||
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
||||||
await claimWorkspaceInvites(user!);
|
await claimWorkspaceInvites(user!);
|
||||||
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
const activeWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||||
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
||||||
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
||||||
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
||||||
@@ -2254,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) => {
|
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);
|
||||||
|
|
||||||
@@ -2492,15 +2778,6 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||||
return;
|
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({
|
const workspace = await createWorkspace({
|
||||||
@@ -2514,10 +2791,20 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (workspace?.workspace_type === 'rescue') {
|
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({
|
await sendRescueStatusNotification({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail: req.auth!.user.email,
|
ownerEmail: req.auth!.user.email,
|
||||||
event: 'created',
|
event: 'created',
|
||||||
|
note: onboardingWebhookError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2565,15 +2852,6 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||||
return;
|
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({
|
const workspace = await updateWorkspace({
|
||||||
@@ -2586,10 +2864,20 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
|
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({
|
await sendRescueStatusNotification({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail: req.auth!.user.email,
|
ownerEmail: req.auth!.user.email,
|
||||||
event: 'converted',
|
event: 'converted',
|
||||||
|
note: onboardingWebhookError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2606,13 +2894,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
|
||||||
|
|
||||||
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!nextWorkspaceId) {
|
if (!nextWorkspaceId) {
|
||||||
const fallbackWorkspaceId = await getNextWorkspaceId();
|
const fallbackWorkspaceId = await getNextWorkspaceId();
|
||||||
const fallbackWorkspace = await createWorkspace({
|
const fallbackWorkspace = await createWorkspace({
|
||||||
id: fallbackWorkspaceId,
|
id: fallbackWorkspaceId,
|
||||||
name: `${req.auth!.user.name}'s Flock`,
|
name: 'New Flock',
|
||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
billingEmail: req.auth!.user.email,
|
billingEmail: req.auth!.user.email,
|
||||||
billingPlan: 'household_basic',
|
billingPlan: 'household_basic',
|
||||||
@@ -2641,6 +2931,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
deletedWorkspaceId: req.auth!.workspace.id,
|
deletedWorkspaceId: req.auth!.workspace.id,
|
||||||
|
canceledStripeSubscriptionId,
|
||||||
token: req.auth!.token,
|
token: req.auth!.token,
|
||||||
session: await buildSessionPayload(updatedAuth),
|
session: await buildSessionPayload(updatedAuth),
|
||||||
});
|
});
|
||||||
@@ -2821,6 +3112,9 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: normalizeBandId(parsed.data.tagId),
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
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,
|
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),
|
||||||
@@ -2831,6 +3125,8 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||||
|
publicProfileCode: createPublicProfileCode(),
|
||||||
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadedObjectKeyToCleanup = null;
|
uploadedObjectKeyToCleanup = null;
|
||||||
@@ -2958,6 +3254,9 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: normalizeBandId(parsed.data.tagId),
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
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,
|
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),
|
||||||
@@ -2968,6 +3267,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||||
|
publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(),
|
||||||
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bird) {
|
if (!bird) {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ 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',
|
||||||
@@ -61,10 +64,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
WHERE workspace_type = 'rescue'
|
WHERE workspace_type = 'rescue'
|
||||||
AND rescue_verification_status = 'not_required';
|
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 (
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
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
|
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,
|
||||||
@@ -216,6 +246,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
tag_id VARCHAR(80),
|
tag_id VARCHAR(80),
|
||||||
species VARCHAR(120) NOT NULL,
|
species VARCHAR(120) NOT NULL,
|
||||||
|
motivators VARCHAR(1000),
|
||||||
|
demotivators VARCHAR(1000),
|
||||||
|
favorite_snack 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,
|
||||||
@@ -226,6 +259,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
photo_updated_at TIMESTAMPTZ,
|
photo_updated_at TIMESTAMPTZ,
|
||||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
notify_on_gotcha_day 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_at TIMESTAMPTZ,
|
||||||
memorialized_on DATE,
|
memorialized_on DATE,
|
||||||
memorial_note VARCHAR(1000),
|
memorial_note VARCHAR(1000),
|
||||||
@@ -235,6 +270,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
ALTER TABLE birds
|
ALTER TABLE birds
|
||||||
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
|
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 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,
|
||||||
@@ -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 photo_updated_at TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
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 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_at TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
||||||
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
||||||
@@ -267,6 +307,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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
|
ALTER TABLE birds
|
||||||
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
||||||
|
|
||||||
@@ -297,6 +343,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ON birds (photo_object_key)
|
ON birds (photo_object_key)
|
||||||
WHERE photo_object_key IS NOT NULL;
|
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 (
|
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,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
name: 'Kiwi',
|
name: 'Kiwi',
|
||||||
tag_id: 'A-1',
|
tag_id: 'A-1',
|
||||||
species: 'Cockatiel',
|
species: 'Cockatiel',
|
||||||
|
motivators: 'Step-up practice',
|
||||||
|
demotivators: 'Vacuum noise',
|
||||||
|
favorite_snack: 'Millet',
|
||||||
gender: 'female',
|
gender: 'female',
|
||||||
date_of_birth: null,
|
date_of_birth: null,
|
||||||
gotcha_day: null,
|
gotcha_day: null,
|
||||||
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
name: 'Kiwi',
|
name: 'Kiwi',
|
||||||
tagId: 'A-1',
|
tagId: 'A-1',
|
||||||
species: 'Cockatiel',
|
species: 'Cockatiel',
|
||||||
|
motivators: 'Step-up practice',
|
||||||
|
demotivators: 'Vacuum noise',
|
||||||
|
favoriteSnack: 'Millet',
|
||||||
gender: 'female',
|
gender: 'female',
|
||||||
dateOfBirth: null,
|
dateOfBirth: null,
|
||||||
gotchaDay: null,
|
gotchaDay: null,
|
||||||
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
assert.equal(bird?.name, 'Kiwi');
|
assert.equal(bird?.name, 'Kiwi');
|
||||||
assert.equal(bird?.workspace_id, 10);
|
assert.equal(bird?.workspace_id, 10);
|
||||||
assert.equal(bird?.gender, 'female');
|
assert.equal(bird?.gender, 'female');
|
||||||
|
assert.equal(bird?.favorite_snack, 'Millet');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const birdSelectFields = `
|
|||||||
birds.name,
|
birds.name,
|
||||||
birds.tag_id,
|
birds.tag_id,
|
||||||
birds.species,
|
birds.species,
|
||||||
|
birds.motivators,
|
||||||
|
birds.demotivators,
|
||||||
|
birds.favorite_snack,
|
||||||
birds.gender,
|
birds.gender,
|
||||||
birds.date_of_birth::text,
|
birds.date_of_birth::text,
|
||||||
birds.gotcha_day::text,
|
birds.gotcha_day::text,
|
||||||
@@ -30,6 +33,8 @@ const birdSelectFields = `
|
|||||||
birds.photo_updated_at,
|
birds.photo_updated_at,
|
||||||
birds.notify_on_dob,
|
birds.notify_on_dob,
|
||||||
birds.notify_on_gotcha_day,
|
birds.notify_on_gotcha_day,
|
||||||
|
birds.public_profile_code,
|
||||||
|
birds.public_profile_enabled,
|
||||||
birds.memorialized_at,
|
birds.memorialized_at,
|
||||||
birds.memorialized_on::text,
|
birds.memorialized_on::text,
|
||||||
birds.memorial_note,
|
birds.memorial_note,
|
||||||
@@ -59,6 +64,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => {
|
|||||||
return result.rows[0] ?? null;
|
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) => {
|
export const listBirds = async (workspaceId: number) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -258,6 +284,9 @@ export const createBird = async ({
|
|||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -268,12 +297,17 @@ export const createBird = async ({
|
|||||||
photoUpdatedAt = null,
|
photoUpdatedAt = null,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode = null,
|
||||||
|
publicProfileEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
birdId?: string;
|
birdId?: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string | null;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favoriteSnack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -284,17 +318,22 @@ export const createBird = async ({
|
|||||||
photoUpdatedAt?: string | null;
|
photoUpdatedAt?: string | null;
|
||||||
notifyOnDob: boolean;
|
notifyOnDob: boolean;
|
||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
|
publicProfileCode?: string | null;
|
||||||
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
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)
|
`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)
|
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, 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`,
|
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,
|
birdId ?? null,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -305,6 +344,8 @@ export const createBird = async ({
|
|||||||
photoUpdatedAt,
|
photoUpdatedAt,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -317,6 +358,9 @@ export const updateBird = async ({
|
|||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -327,12 +371,17 @@ export const updateBird = async ({
|
|||||||
photoUpdatedAt = null,
|
photoUpdatedAt = null,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
}: {
|
}: {
|
||||||
birdId: string;
|
birdId: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string | null;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favoriteSnack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
@@ -343,26 +392,33 @@ export const updateBird = async ({
|
|||||||
photoUpdatedAt?: string | null;
|
photoUpdatedAt?: string | null;
|
||||||
notifyOnDob: boolean;
|
notifyOnDob: boolean;
|
||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
|
publicProfileCode: string | null;
|
||||||
|
publicProfileEnabled: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`UPDATE birds
|
`UPDATE birds
|
||||||
SET name = $2,
|
SET name = $2,
|
||||||
tag_id = $3,
|
tag_id = $3,
|
||||||
species = $4,
|
species = $4,
|
||||||
gender = $5,
|
motivators = $5,
|
||||||
date_of_birth = $6,
|
demotivators = $6,
|
||||||
gotcha_day = $7,
|
favorite_snack = $7,
|
||||||
chart_color = $8,
|
gender = $8,
|
||||||
photo_data_url = $9,
|
date_of_birth = $9,
|
||||||
photo_object_key = $10,
|
gotcha_day = $10,
|
||||||
photo_content_type = $11,
|
chart_color = $11,
|
||||||
photo_updated_at = $12,
|
photo_data_url = $12,
|
||||||
notify_on_dob = $13,
|
photo_object_key = $13,
|
||||||
notify_on_gotcha_day = $14
|
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
|
WHERE id = $1
|
||||||
AND workspace_id = $15
|
AND workspace_id = $20
|
||||||
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, 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
|
||||||
@@ -382,6 +438,9 @@ export const updateBird = async ({
|
|||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
@@ -392,6 +451,8 @@ export const updateBird = async ({
|
|||||||
photoUpdatedAt,
|
photoUpdatedAt,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
workspaceId,
|
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 {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
findAlternateWorkspaceForUser,
|
findAlternateWorkspaceForUser,
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
@@ -34,6 +35,83 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
|
|||||||
assert.match(calls[0].text, /FROM workspace_members/);
|
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 () => {
|
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
||||||
const { calls } = mockDb(
|
const { calls } = mockDb(
|
||||||
{ rowCount: 1, rows: [] },
|
{ rowCount: 1, rows: [] },
|
||||||
|
|||||||
@@ -91,39 +91,13 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
return Number(existing.rows[0].workspace_id);
|
return Number(existing.rows[0].workspace_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unclaimed = await db.query<{ workspace_id: number }>(
|
const workspaceId = await getNextWorkspaceId();
|
||||||
`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 = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
|
|
||||||
|
|
||||||
if (!unclaimed.rowCount) {
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
|
`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')`,
|
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
[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(
|
await db.query(
|
||||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||||
@@ -140,6 +114,24 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
return workspaceId;
|
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) => {
|
export const claimWorkspaceInvites = async (user: UserRow) => {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE workspace_members
|
`UPDATE workspace_members
|
||||||
@@ -388,7 +380,6 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
|
|||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
owner_email: string | null;
|
|
||||||
bird_count: number;
|
bird_count: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
}
|
}
|
||||||
@@ -406,17 +397,13 @@ export const listRescueWorkspacesForAdmin = async () => {
|
|||||||
workspaces.rescue_verification_status,
|
workspaces.rescue_verification_status,
|
||||||
workspaces.created_at,
|
workspaces.created_at,
|
||||||
workspaces.updated_at,
|
workspaces.updated_at,
|
||||||
owner.invite_email AS owner_email,
|
|
||||||
COUNT(DISTINCT birds.id)::int AS bird_count,
|
COUNT(DISTINCT birds.id)::int AS bird_count,
|
||||||
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
||||||
FROM workspaces
|
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 birds ON birds.workspace_id = workspaces.id
|
||||||
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
||||||
WHERE workspaces.workspace_type = 'rescue'
|
WHERE workspaces.workspace_type = 'rescue'
|
||||||
GROUP BY workspaces.id, owner.invite_email
|
GROUP BY workspaces.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE workspaces.rescue_verification_status
|
CASE workspaces.rescue_verification_status
|
||||||
WHEN 'pending' THEN 0
|
WHEN 'pending' THEN 0
|
||||||
|
|||||||
@@ -13,9 +13,38 @@ 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;
|
||||||
@@ -98,6 +127,9 @@ export type BirdRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
tag_id: string | null;
|
tag_id: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favorite_snack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
gotcha_day: string | null;
|
gotcha_day: string | null;
|
||||||
@@ -108,6 +140,8 @@ export type BirdRow = {
|
|||||||
photo_updated_at: string | null;
|
photo_updated_at: string | null;
|
||||||
notify_on_dob: boolean;
|
notify_on_dob: boolean;
|
||||||
notify_on_gotcha_day: boolean;
|
notify_on_gotcha_day: boolean;
|
||||||
|
public_profile_code: string | null;
|
||||||
|
public_profile_enabled: boolean;
|
||||||
memorialized_at: string | null;
|
memorialized_at: string | null;
|
||||||
memorialized_on: string | null;
|
memorialized_on: string | null;
|
||||||
memorial_note: string | null;
|
memorial_note: string | null;
|
||||||
|
|||||||
Generated
+497
-1
@@ -8,8 +8,11 @@
|
|||||||
"name": "flockpal-frontend",
|
"name": "flockpal-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
@@ -1144,6 +1147,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1151,6 +1163,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.12",
|
"version": "18.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
"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"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.16",
|
"version": "2.10.16",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
||||||
@@ -1205,6 +1259,12 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"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": "^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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001786",
|
"version": "1.0.30001786",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||||
@@ -1260,6 +1329,35 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1267,6 +1365,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.331",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
||||||
@@ -1299,6 +1427,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1348,6 +1482,39 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1373,6 +1540,42 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1405,6 +1608,30 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"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": "^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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.37",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
||||||
@@ -1460,6 +1693,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1467,6 +1745,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -1496,6 +1783,29 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -1531,6 +1841,50 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -1576,6 +1930,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1595,6 +1955,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1605,6 +1971,41 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||||
@@ -1619,6 +2020,34 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -1650,6 +2079,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.10",
|
"version": "5.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
+2144
-276
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
+497
-9
@@ -122,6 +122,70 @@ textarea {
|
|||||||
gap: 1.5rem;
|
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 {
|
.side-rail {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 2rem;
|
top: 2rem;
|
||||||
@@ -155,6 +219,53 @@ textarea {
|
|||||||
align-items: stretch;
|
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 {
|
.auth-hero-card {
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@@ -509,22 +620,26 @@ textarea {
|
|||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-collaborators {
|
.settings-card-bird-profiles[hidden] {
|
||||||
order: 2;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-separate-flock {
|
.settings-card-collaborators {
|
||||||
order: 3;
|
order: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-automation {
|
.settings-card-automation {
|
||||||
order: 4;
|
order: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-transfer {
|
.settings-card-bird-import {
|
||||||
order: 5;
|
order: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-card-transfer {
|
||||||
|
order: 6;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card-flock-profile {
|
.settings-card-flock-profile {
|
||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
@@ -567,6 +682,21 @@ textarea {
|
|||||||
gap: 1rem;
|
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 {
|
.settings-danger-card {
|
||||||
border-color: rgba(203, 58, 53, 0.22);
|
border-color: rgba(203, 58, 53, 0.22);
|
||||||
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
||||||
@@ -615,12 +745,141 @@ 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;
|
||||||
flex-wrap: wrap;
|
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 {
|
.billing-inline-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -896,6 +1155,17 @@ textarea {
|
|||||||
font-size: 11px;
|
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-line,
|
||||||
.historical-weight-dot {
|
.historical-weight-dot {
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
@@ -941,6 +1211,32 @@ textarea {
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.profile-copy {
|
||||||
@@ -953,8 +1249,7 @@ textarea {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-title,
|
.profile-title {
|
||||||
.detail-gender {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -1052,6 +1347,25 @@ textarea {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.care-form-actions {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -1120,6 +1434,21 @@ textarea {
|
|||||||
font-size: 1.05rem;
|
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 {
|
.summary-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -1364,6 +1693,21 @@ label {
|
|||||||
accent-color: var(--accent-green);
|
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 {
|
.primary-button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -1548,12 +1892,85 @@ label {
|
|||||||
gap: 1rem;
|
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 {
|
.modal-alert-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
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) {
|
@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,
|
||||||
@@ -1570,6 +1987,7 @@ label {
|
|||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
@@ -1581,11 +1999,81 @@ label {
|
|||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav {
|
.top-alert-notification {
|
||||||
position: static;
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: static;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user