Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2017068d5 | |||
| c9702495a3 | |||
| e965cb55ef | |||
| 505a9b8496 | |||
| c6dc5b22b8 | |||
| d2763744eb | |||
| 841d0a9669 | |||
| b4f6193395 | |||
| 9ee46e53e0 | |||
| f16e88e2f0 | |||
| 016bc187d4 | |||
| 104f01f75d | |||
| 613b2c941c | |||
| 3ab3f48f19 | |||
| d9822e6626 | |||
| 9e92e1212a | |||
| 96bc76ef0d | |||
| fe4e69ceb5 | |||
| c09e7f63ce | |||
| 306e3a8c85 | |||
| 568aee3e70 | |||
| a502966293 | |||
| b7186528c5 | |||
| 49d75f34be | |||
| df3fcbf885 | |||
| 4715306d14 | |||
| 62afc94f2f | |||
| e6211d7f5e | |||
| cf3cd96384 | |||
| 38dcb7f49b | |||
| 1c0d57299d | |||
| f2c506ec16 |
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
+6
-3
@@ -29,9 +29,12 @@ STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK=
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW=
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY=
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW=
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY=
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
|
||||
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
||||
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
||||
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
- /docker/FlockPal-dev:/docker/FlockPal-dev
|
||||
steps:
|
||||
- name: Update dev checkout
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal-dev
|
||||
git fetch --all --prune
|
||||
git reset --hard "origin/${{ github.ref_name }}"
|
||||
git clean -fd
|
||||
|
||||
- name: Validate backend
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal-dev
|
||||
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
|
||||
|
||||
- name: Validate frontend
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal-dev
|
||||
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
|
||||
|
||||
- name: Validate dev compose config
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal-dev
|
||||
docker compose -f docker-compose.dev.yaml config --quiet
|
||||
|
||||
- name: Deploy dev
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal-dev
|
||||
docker compose -f docker-compose.dev.yaml up -d --build
|
||||
|
||||
deploy-prod:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
- /docker/FlockPal:/docker/FlockPal
|
||||
steps:
|
||||
- name: Update prod checkout
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal
|
||||
git fetch --all --prune
|
||||
git reset --hard "origin/${{ github.ref_name }}"
|
||||
git clean -fd
|
||||
|
||||
- name: Validate backend
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal
|
||||
docker run --rm -v "$PWD/backend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build && npm test"
|
||||
|
||||
- name: Validate frontend
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal
|
||||
docker run --rm -v "$PWD/frontend:/src:ro" -w /tmp/app node:22-alpine sh -lc "cp -a /src/. /tmp/app && npm ci && npm run build"
|
||||
|
||||
- name: Validate prod compose config
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal
|
||||
docker compose -f docker-compose.prod.yml config --quiet
|
||||
|
||||
- name: Deploy prod
|
||||
run: |
|
||||
set -e
|
||||
cd /docker/FlockPal
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
@@ -7,3 +7,4 @@ frontend/dist
|
||||
data/
|
||||
backups/
|
||||
.DS_Store
|
||||
docker-compose.dev.yaml
|
||||
@@ -203,8 +203,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled
|
||||
- `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW`
|
||||
- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY`
|
||||
- `STRIPE_CHECKOUT_SUCCESS_URL`
|
||||
- `STRIPE_CHECKOUT_CANCEL_URL`
|
||||
- `STRIPE_PORTAL_RETURN_URL`
|
||||
@@ -221,7 +223,7 @@ Recommended defaults:
|
||||
- Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes.
|
||||
- Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`.
|
||||
- Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`.
|
||||
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`.
|
||||
- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_macaw`.
|
||||
- After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later.
|
||||
|
||||
For local development with the Stripe CLI:
|
||||
|
||||
+384
-53
@@ -44,6 +44,7 @@ import {
|
||||
deleteMedicationForBird,
|
||||
deleteVetVisitForBird,
|
||||
getBirdById,
|
||||
getBirdByPublicProfileCode,
|
||||
listBirds,
|
||||
listDueBirdMilestoneReminders,
|
||||
listMemorializedBirds,
|
||||
@@ -60,6 +61,13 @@ import {
|
||||
updateVetVisitForBird,
|
||||
} from './repositories/birdRepository.js';
|
||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||
import {
|
||||
createAuditLogEntry,
|
||||
createFlockNote,
|
||||
deleteFlockNote,
|
||||
listAuditLogEntries,
|
||||
listFlockNotes,
|
||||
} from './repositories/auditRepository.js';
|
||||
import {
|
||||
buildBirdPhotoObjectKey,
|
||||
getImageExtensionFromContentType,
|
||||
@@ -73,12 +81,14 @@ import {
|
||||
createWorkspace,
|
||||
deleteWorkspaceMember,
|
||||
deleteWorkspaceIfEmpty,
|
||||
ensureDefaultWorkspaceForUser,
|
||||
ensurePersonalWorkspaceForUser,
|
||||
findAlternateWorkspaceForUser,
|
||||
getPlatformAdminSummary,
|
||||
getMembershipForUser,
|
||||
getNextWorkspaceId,
|
||||
getWorkspaceById,
|
||||
getWorkspaceBirdCount,
|
||||
getWorkspaceTotalBirdCount,
|
||||
listOwnedWorkspacesByOwnerEmail,
|
||||
listRescueWorkspacesForAdmin,
|
||||
@@ -94,11 +104,13 @@ import {
|
||||
} from './repositories/workspaceRepository.js';
|
||||
import type {
|
||||
AuthContext,
|
||||
AuditLogEntryRow,
|
||||
BillingInterval,
|
||||
BillingPlan,
|
||||
BirdGender,
|
||||
BirdMilestoneReminderCandidateRow,
|
||||
BirdRow,
|
||||
FlockNoteRow,
|
||||
IntegrationTokenRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationRow,
|
||||
@@ -182,7 +194,7 @@ const switchWorkspaceSchema = z.object({
|
||||
|
||||
const workspaceTypeSchema = z.enum(['standard', 'rescue']);
|
||||
const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']);
|
||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']);
|
||||
const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']);
|
||||
const billingIntervalSchema = z.enum(['monthly', 'yearly']);
|
||||
const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']);
|
||||
const birdGenderSchema = z.enum(['unknown', 'male', 'female']);
|
||||
@@ -231,12 +243,24 @@ const lostBirdReportSchema = z.object({
|
||||
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
|
||||
const birdProfileListSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000)
|
||||
.refine(
|
||||
(value) => value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).length <= 3,
|
||||
'Use no more than three list items.',
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal(''));
|
||||
|
||||
const birdSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||
species: z.string().trim().min(1).max(120),
|
||||
motivators: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
demotivators: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
motivators: birdProfileListSchema,
|
||||
demotivators: birdProfileListSchema,
|
||||
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
||||
gender: birdGenderSchema.optional(),
|
||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||
@@ -245,6 +269,7 @@ const birdSchema = z.object({
|
||||
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
|
||||
notifyOnDob: z.boolean().optional(),
|
||||
notifyOnGotchaDay: z.boolean().optional(),
|
||||
publicProfileEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const memorializeBirdSchema = z.object({
|
||||
@@ -302,6 +327,11 @@ const medicationAdministrationSchema = z.object({
|
||||
notes: z.string().trim().max(500).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
const flockNoteSchema = z.object({
|
||||
birdId: z.string().uuid().optional().nullable().or(z.literal('')),
|
||||
body: z.string().trim().min(1).max(5000),
|
||||
});
|
||||
|
||||
const integrationTokenCreateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160),
|
||||
scope: integrationTokenScopeSchema.default('read_write'),
|
||||
@@ -325,18 +355,23 @@ const hashToken = (token: string) => crypto.createHash('sha256').update(token).d
|
||||
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
||||
const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken();
|
||||
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
||||
const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url');
|
||||
const createRandomId = () => crypto.randomUUID();
|
||||
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
||||
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
|
||||
const resolveBillingPlan = (
|
||||
workspaceType: WorkspaceType,
|
||||
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw',
|
||||
requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw',
|
||||
) => {
|
||||
if (workspaceType === 'rescue') {
|
||||
return 'rescue_free' as const;
|
||||
}
|
||||
|
||||
if (requestedPlan === 'household_hyacinth_macaw') {
|
||||
return 'household_hyacinth_macaw';
|
||||
}
|
||||
|
||||
if (requestedPlan === 'household_macaw') {
|
||||
return 'household_macaw';
|
||||
}
|
||||
@@ -388,8 +423,18 @@ const stripePriceByBillingPlanAndInterval: Partial<Record<Exclude<BillingPlan, '
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY?.trim() ?? '',
|
||||
},
|
||||
household_macaw: {
|
||||
monthly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY?.trim() || process.env.STRIPE_PRICE_HOUSEHOLD_MACAW?.trim() || '',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY?.trim() ?? '',
|
||||
monthly:
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY?.trim() ||
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY?.trim() ||
|
||||
'',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY?.trim() ?? '',
|
||||
},
|
||||
household_hyacinth_macaw: {
|
||||
monthly:
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY?.trim() ||
|
||||
process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW?.trim() ||
|
||||
'',
|
||||
yearly: process.env.STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY?.trim() ?? '',
|
||||
},
|
||||
};
|
||||
const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, 'rescue_free'>, Record<BillingInterval, string[]>> = {
|
||||
@@ -402,14 +447,19 @@ const stripePriceEnvNamesByBillingPlanAndInterval: Record<Exclude<BillingPlan, '
|
||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY'],
|
||||
},
|
||||
household_macaw: {
|
||||
monthly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_MACAW'],
|
||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY'],
|
||||
monthly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY'],
|
||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY'],
|
||||
},
|
||||
household_hyacinth_macaw: {
|
||||
monthly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY', 'STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW'],
|
||||
yearly: ['STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY'],
|
||||
},
|
||||
};
|
||||
const stripePricePlanLabels: Record<Exclude<BillingPlan, 'rescue_free'>, string> = {
|
||||
household_basic: 'Conure',
|
||||
household_plus: 'Indian Ringneck',
|
||||
household_macaw: 'Macaw',
|
||||
household_macaw: 'African Grey',
|
||||
household_hyacinth_macaw: 'Hyacinth Macaw',
|
||||
};
|
||||
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
||||
const adminEmails = new Set(
|
||||
@@ -462,13 +512,11 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
||||
|
||||
const normalizeAdminRescueWorkspace = (
|
||||
row: WorkspaceRow & {
|
||||
owner_email: string | null;
|
||||
bird_count: number;
|
||||
member_count: number;
|
||||
},
|
||||
) => ({
|
||||
workspace: normalizeWorkspace(row),
|
||||
ownerEmail: row.owner_email,
|
||||
birdCount: Number(row.bird_count ?? 0),
|
||||
memberCount: Number(row.member_count ?? 0),
|
||||
});
|
||||
@@ -579,6 +627,8 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
photoUpdatedAt: row.photo_updated_at,
|
||||
notifyOnDob: row.notify_on_dob,
|
||||
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
||||
publicProfileCode: row.public_profile_code ?? null,
|
||||
publicProfileEnabled: row.public_profile_enabled ?? false,
|
||||
memorializedAt: row.memorialized_at,
|
||||
memorializedOn: row.memorialized_on,
|
||||
memorialNote: row.memorial_note,
|
||||
@@ -588,6 +638,16 @@ const normalizeBird = (row: BirdRow) => ({
|
||||
latestRecordedOn: row.latest_recorded_on,
|
||||
});
|
||||
|
||||
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
name: row.name,
|
||||
favoriteSnack: row.favorite_snack,
|
||||
gender: row.gender,
|
||||
dateOfBirth: row.date_of_birth,
|
||||
photoDataUrl: getBirdPhotoUrl(row),
|
||||
});
|
||||
|
||||
const normalizeWeight = (row: WeightRow) => ({
|
||||
id: row.id,
|
||||
birdId: row.bird_id,
|
||||
@@ -630,6 +690,33 @@ const normalizeMedicationAdministration = (row: MedicationAdministrationRow) =>
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const normalizeFlockNote = (row: FlockNoteRow) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
birdId: row.bird_id,
|
||||
birdName: row.bird_name,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
createdByUserId: row.created_by_user_id,
|
||||
createdByName: row.created_by_name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
});
|
||||
|
||||
const normalizeAuditLogEntry = (row: AuditLogEntryRow) => ({
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
userId: row.user_id,
|
||||
actorName: row.actor_name,
|
||||
actorEmail: row.actor_email,
|
||||
action: row.action,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
entityName: row.entity_name,
|
||||
details: row.details,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
@@ -851,6 +938,26 @@ const subscriptionAllowsWrite = (workspace: WorkspaceRow) => {
|
||||
return workspace.subscription_status === 'active' || workspace.subscription_status === 'trialing';
|
||||
};
|
||||
|
||||
const getBillingPlanBirdLimit = (billingPlan: BillingPlan) => {
|
||||
if (billingPlan === 'rescue_free') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (billingPlan === 'household_basic') {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (billingPlan === 'household_plus') {
|
||||
return 10;
|
||||
}
|
||||
|
||||
if (billingPlan === 'household_macaw') {
|
||||
return 16;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapStripeSubscriptionStatus = (status: Stripe.Subscription.Status): SubscriptionStatus => {
|
||||
if (status === 'active' || status === 'trialing' || status === 'past_due' || status === 'canceled' || status === 'unpaid') {
|
||||
return status;
|
||||
@@ -1249,10 +1356,12 @@ const sendRescueStatusNotification = async ({
|
||||
workspace,
|
||||
ownerEmail,
|
||||
event,
|
||||
note,
|
||||
}: {
|
||||
workspace: WorkspaceRow;
|
||||
ownerEmail: string | null;
|
||||
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
||||
note?: string | null;
|
||||
}) => {
|
||||
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
||||
const eventLabel =
|
||||
@@ -1276,6 +1385,11 @@ const sendRescueStatusNotification = async ({
|
||||
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
||||
`Flock ID: ${workspace.id}`,
|
||||
];
|
||||
const escapedNote = note ? escapeHtml(note) : null;
|
||||
|
||||
if (note) {
|
||||
lines.push(`Note: ${note}`);
|
||||
}
|
||||
|
||||
if (!mailTransport) {
|
||||
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
||||
@@ -1296,6 +1410,7 @@ const sendRescueStatusNotification = async ({
|
||||
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
|
||||
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
||||
</ul>
|
||||
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -1350,6 +1465,17 @@ const sendRescueOnboardingWebhook = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const trySendRescueOnboardingWebhook = async (payload: Parameters<typeof sendRescueOnboardingWebhook>[0]) => {
|
||||
try {
|
||||
await sendRescueOnboardingWebhook(payload);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown rescue onboarding webhook error.';
|
||||
console.error(`Rescue onboarding webhook failed for workspace ${payload.workspaceId}:`, error);
|
||||
return `The rescue onboarding webhook failed and this rescue requires manual review. ${errorMessage}`;
|
||||
}
|
||||
};
|
||||
|
||||
const issueMagicLinkInvite = async ({
|
||||
email,
|
||||
name,
|
||||
@@ -1851,6 +1977,29 @@ const ensureBirdWritable = (bird: BirdRow, res: Response) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const writeAuditLog = async (
|
||||
auth: AuthContext,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId?: string | null,
|
||||
entityName?: string | null,
|
||||
details?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
await createAuditLogEntry({
|
||||
workspaceId: auth.workspace.id,
|
||||
auth,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
entityName,
|
||||
details,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to write audit log entry', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isBillingOnlyWorkspaceUpdate = (
|
||||
workspace: WorkspaceRow,
|
||||
payload: z.infer<typeof workspaceSchema>,
|
||||
@@ -1949,6 +2098,28 @@ app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, re
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/public/birds/:publicProfileCode', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const parsed = publicProfileCodeSchema.safeParse(req.params.publicProfileCode);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(404).json({ error: 'Public bird profile not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bird = await getBirdByPublicProfileCode(parsed.data);
|
||||
|
||||
if (!bird) {
|
||||
res.status(404).json({ error: 'Public bird profile not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ bird: normalizePublicBirdProfile(bird) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
providers: Object.values(oauthProviders).map((provider) => ({
|
||||
@@ -2022,7 +2193,7 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
|
||||
}
|
||||
|
||||
await claimWorkspaceInvites(user!);
|
||||
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
||||
const receivingWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
||||
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
||||
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
||||
@@ -2230,7 +2401,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
|
||||
|
||||
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
||||
await claimWorkspaceInvites(user!);
|
||||
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
||||
const activeWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
||||
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
||||
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
||||
@@ -2462,7 +2633,11 @@ app.post('/api/integration-tokens', requireAuth, requireSessionAuth, requireWrit
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
await writeAuditLog(req.auth!, 'integration_token.created', 'integration_token', integrationToken!.id, integrationToken!.name, {
|
||||
scope: integrationToken!.scope,
|
||||
expiresAt: integrationToken!.expires_at,
|
||||
});
|
||||
res.status(201).json({
|
||||
integrationToken: normalizeIntegrationToken(integrationToken!),
|
||||
token: rawToken,
|
||||
});
|
||||
@@ -2513,15 +2688,6 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await sendRescueOnboardingWebhook({
|
||||
action: 'created',
|
||||
workspaceId,
|
||||
flockName: parsed.data.name,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
requestedByUserId: req.auth!.user.id,
|
||||
rescueOnboarding: parsed.data.rescueOnboarding,
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await createWorkspace({
|
||||
@@ -2535,10 +2701,20 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
||||
});
|
||||
|
||||
if (workspace?.workspace_type === 'rescue') {
|
||||
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
|
||||
action: 'created',
|
||||
workspaceId: workspace.id,
|
||||
flockName: workspace.name,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
requestedByUserId: req.auth!.user.id,
|
||||
rescueOnboarding: parsed.data.rescueOnboarding!,
|
||||
});
|
||||
|
||||
await sendRescueStatusNotification({
|
||||
workspace,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
event: 'created',
|
||||
note: onboardingWebhookError,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2586,15 +2762,6 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await sendRescueOnboardingWebhook({
|
||||
action: 'converted',
|
||||
workspaceId: currentWorkspace.id,
|
||||
flockName: parsed.data.name,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
requestedByUserId: req.auth!.user.id,
|
||||
rescueOnboarding: parsed.data.rescueOnboarding,
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await updateWorkspace({
|
||||
@@ -2607,14 +2774,28 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
||||
});
|
||||
|
||||
if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
|
||||
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
|
||||
action: 'converted',
|
||||
workspaceId: workspace.id,
|
||||
flockName: workspace.name,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
requestedByUserId: req.auth!.user.id,
|
||||
rescueOnboarding: parsed.data.rescueOnboarding!,
|
||||
});
|
||||
|
||||
await sendRescueStatusNotification({
|
||||
workspace,
|
||||
ownerEmail: req.auth!.user.email,
|
||||
event: 'converted',
|
||||
note: onboardingWebhookError,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ workspace: normalizeWorkspace(workspace!) });
|
||||
await writeAuditLog(req.auth!, 'workspace.updated', 'workspace', String(workspace!.id), workspace!.name, {
|
||||
workspaceType: workspace!.workspace_type,
|
||||
billingPlan: workspace!.billing_plan,
|
||||
});
|
||||
res.json({ workspace: normalizeWorkspace(workspace!) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -2728,7 +2909,11 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
|
||||
existingUser,
|
||||
});
|
||||
|
||||
res.status(201).json({ member: normalizeWorkspaceMember(member!) });
|
||||
await writeAuditLog(req.auth!, 'workspace_member.upserted', 'workspace_member', member!.id, member!.name, {
|
||||
inviteEmail: member!.invite_email,
|
||||
role: member!.role,
|
||||
});
|
||||
res.status(201).json({ member: normalizeWorkspaceMember(member!) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -2743,12 +2928,80 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess,
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
|
||||
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/notes', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const notes = await listFlockNotes(req.auth!.workspace.id);
|
||||
res.json({ notes: notes.map(normalizeFlockNote) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notes', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
const parsed = flockNoteSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Invalid note payload', details: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const note = await createFlockNote({
|
||||
workspaceId: req.auth!.workspace.id,
|
||||
birdId: emptyToNull(parsed.data.birdId ?? ''),
|
||||
body: parsed.data.body,
|
||||
createdByUserId: req.auth!.user.id,
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
res.status(404).json({ error: 'Flock member not found for this note.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req.auth!, 'note.created', 'note', note.id, note.bird_name ?? 'Note', {
|
||||
birdId: note.bird_id,
|
||||
birdName: note.bird_name,
|
||||
});
|
||||
res.status(201).json({ note: normalizeFlockNote(note) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/notes/:noteId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const deleted = await deleteFlockNote(req.params.noteId, req.auth!.workspace.id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'Note not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeAuditLog(req.auth!, 'note.deleted', 'note', deleted.id, deleted.title);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/audit-log', requireAuth, requireSessionAuth, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 250);
|
||||
const entries = await listAuditLogEntries(req.auth!.workspace.id, limit);
|
||||
res.json({ entries: entries.map(normalizeAuditLogEntry) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const [birds, memorializedBirds] = await Promise.all([
|
||||
@@ -2832,6 +3085,23 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
let uploadedObjectKeyToCleanup: string | null = null;
|
||||
|
||||
try {
|
||||
const birdLimit = getBillingPlanBirdLimit(req.auth!.workspace.billing_plan);
|
||||
|
||||
if (birdLimit !== null) {
|
||||
const currentBirdCount = await getWorkspaceBirdCount(req.auth!.workspace.id);
|
||||
|
||||
if (currentBirdCount >= birdLimit) {
|
||||
res.status(409).json({
|
||||
error: 'This flock has reached the bird limit for the selected plan. Upgrade the flock subscription or memorialize a bird before adding another.',
|
||||
code: 'billing_plan_bird_limit_reached',
|
||||
birdLimit,
|
||||
currentBirdCount,
|
||||
billingPlan: req.auth!.workspace.billing_plan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const birdId = crypto.randomUUID();
|
||||
const photoStorage = await resolveBirdPhotoStorage({
|
||||
birdId,
|
||||
@@ -2858,10 +3128,16 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||
publicProfileCode: createPublicProfileCode(),
|
||||
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||
});
|
||||
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
await writeAuditLog(req.auth!, 'bird.created', 'bird', bird!.id, bird!.name, {
|
||||
species: bird!.species,
|
||||
tagId: bird!.tag_id,
|
||||
});
|
||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
|
||||
@@ -2912,7 +3188,10 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
||||
redirectTo: frontendBaseUrl,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
await writeAuditLog(req.auth!, 'bird.transfer_invited', 'bird', sourceBird.id, sourceBird.name, {
|
||||
destinationOwnerEmail,
|
||||
});
|
||||
res.status(202).json({
|
||||
ok: true,
|
||||
bird: normalizeBird(sourceBird),
|
||||
destinationOwnerEmail,
|
||||
@@ -2938,7 +3217,11 @@ app.post('/api/birds/:birdId/transfer', requireAuth, requireWriteAccess, require
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
||||
await writeAuditLog(req.auth!, 'bird.transferred', 'bird', bird.id, bird.name, {
|
||||
destinationOwnerEmail,
|
||||
destinationWorkspaceId: targetWorkspace.id,
|
||||
});
|
||||
res.json({ bird: normalizeBird(bird), destinationOwnerEmail, destinationWorkspace: normalizeWorkspace(targetWorkspace) });
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||
res.status(409).json({ error: 'That band/tag ID is already in use in the destination flock.' });
|
||||
@@ -2998,6 +3281,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||
publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(),
|
||||
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||
});
|
||||
|
||||
if (!bird) {
|
||||
@@ -3005,9 +3290,13 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
||||
return;
|
||||
}
|
||||
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
uploadedObjectKeyToCleanup = null;
|
||||
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||
await writeAuditLog(req.auth!, 'bird.updated', 'bird', bird.id, bird.name, {
|
||||
previousName: existingBird.name,
|
||||
species: bird.species,
|
||||
});
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
} catch (error) {
|
||||
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||
|
||||
@@ -3040,7 +3329,8 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
await writeAuditLog(req.auth!, 'bird.deleted', 'bird', bird.id, bird.name);
|
||||
res.status(204).send();
|
||||
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -3080,7 +3370,10 @@ app.post('/api/birds/:birdId/memorialize', requireAuth, requireWriteAccess, requ
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
await writeAuditLog(req.auth!, 'bird.memorialized', 'bird', bird.id, bird.name, {
|
||||
memorializedOn: bird.memorialized_on,
|
||||
});
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3106,7 +3399,10 @@ app.patch('/api/birds/:birdId/memorial-reminders', requireAuth, requireWriteAcce
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
await writeAuditLog(req.auth!, 'bird.memorial_reminder_updated', 'bird', bird.id, bird.name, {
|
||||
notifyOnMemorialDay: bird.notify_on_memorial_day,
|
||||
});
|
||||
res.json({ bird: normalizeBird(bird) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3142,8 +3438,13 @@ app.post('/api/birds/:birdId/weights', requireAuth, requireWriteAccess, requireW
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
||||
res.status(201).json({ weight: normalizeWeight(weight!) });
|
||||
const weight = await createWeightForBird(req.params.birdId, parsed.data.weightGrams, parsed.data.recordedOn, emptyToNull(parsed.data.notes));
|
||||
await writeAuditLog(req.auth!, 'weight.created', 'weight', weight!.id, bird.name, {
|
||||
birdId: bird.id,
|
||||
weightGrams: parsed.data.weightGrams,
|
||||
recordedOn: parsed.data.recordedOn,
|
||||
});
|
||||
res.status(201).json({ weight: normalizeWeight(weight!) });
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||
res.status(409).json({ error: 'A weight entry already exists for that bird on that date.' });
|
||||
@@ -3191,7 +3492,12 @@ app.post('/api/birds/:birdId/vet-visits', requireAuth, requireWriteAccess, requi
|
||||
emptyToNull(parsed.data.notes),
|
||||
);
|
||||
|
||||
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
|
||||
await writeAuditLog(req.auth!, 'vet_visit.created', 'vet_visit', vetVisit!.id, bird.name, {
|
||||
birdId: bird.id,
|
||||
visitedOn: parsed.data.visitedOn,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
res.status(201).json({ vetVisit: normalizeVetVisit(vetVisit!) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3231,7 +3537,12 @@ app.put('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAcces
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
|
||||
await writeAuditLog(req.auth!, 'vet_visit.updated', 'vet_visit', vetVisit.id, bird.name, {
|
||||
birdId: bird.id,
|
||||
visitedOn: parsed.data.visitedOn,
|
||||
reason: parsed.data.reason,
|
||||
});
|
||||
res.json({ vetVisit: normalizeVetVisit(vetVisit) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3257,7 +3568,10 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
await writeAuditLog(req.auth!, 'vet_visit.deleted', 'vet_visit', req.params.visitId, bird.name, {
|
||||
birdId: bird.id,
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3304,7 +3618,11 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
||||
emptyToNull(parsed.data.notes),
|
||||
);
|
||||
|
||||
res.status(201).json({ medication: normalizeMedication(medication!) });
|
||||
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||
birdId: bird.id,
|
||||
birdName: bird.name,
|
||||
});
|
||||
res.status(201).json({ medication: normalizeMedication(medication!) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3348,7 +3666,11 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ medication: normalizeMedication(medication) });
|
||||
await writeAuditLog(req.auth!, 'medication.updated', 'medication', medication.id, medication.name, {
|
||||
birdId: bird.id,
|
||||
birdName: bird.name,
|
||||
});
|
||||
res.json({ medication: normalizeMedication(medication) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3374,7 +3696,10 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
await writeAuditLog(req.auth!, 'medication.deleted', 'medication', req.params.medicationId, bird.name, {
|
||||
birdId: bird.id,
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -3425,7 +3750,13 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
|
||||
await writeAuditLog(req.auth!, 'medication_administration.recorded', 'medication_administration', administration.id, bird.name, {
|
||||
birdId: bird.id,
|
||||
medicationId: req.params.medicationId,
|
||||
administeredOn: parsed.data.administeredOn,
|
||||
status: parsed.data.status,
|
||||
});
|
||||
res.status(201).json({ administration: normalizeMedicationAdministration(administration) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -225,6 +225,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
photo_updated_at TIMESTAMPTZ,
|
||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
public_profile_code VARCHAR(32),
|
||||
public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
memorialized_at TIMESTAMPTZ,
|
||||
memorialized_on DATE,
|
||||
memorial_note VARCHAR(1000),
|
||||
@@ -247,6 +249,8 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
||||
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
||||
@@ -305,8 +309,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ON birds (photo_object_key)
|
||||
WHERE photo_object_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code
|
||||
ON birds (public_profile_code)
|
||||
WHERE public_profile_code IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
source_workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
destination_owner_email VARCHAR(255) NOT NULL,
|
||||
@@ -326,11 +334,49 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
||||
ON pending_bird_transfers (LOWER(destination_owner_email), created_at DESC)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_bird_transfers_open_bird
|
||||
ON pending_bird_transfers (bird_id)
|
||||
WHERE completed_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weight_records (
|
||||
CREATE TABLE IF NOT EXISTS flock_notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
bird_id UUID REFERENCES birds(id) ON DELETE SET NULL,
|
||||
title VARCHAR(160) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_flock_notes_workspace_updated
|
||||
ON flock_notes (workspace_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_flock_notes_bird_updated
|
||||
ON flock_notes (bird_id, updated_at DESC)
|
||||
WHERE bird_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_name VARCHAR(160),
|
||||
actor_email VARCHAR(255),
|
||||
action VARCHAR(80) NOT NULL,
|
||||
entity_type VARCHAR(80) NOT NULL,
|
||||
entity_id VARCHAR(120),
|
||||
entity_name VARCHAR(255),
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_workspace_created
|
||||
ON audit_log_entries (workspace_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_entries_entity
|
||||
ON audit_log_entries (workspace_id, entity_type, entity_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weight_records (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||
weight_grams NUMERIC(8, 2) NOT NULL CHECK (weight_grams > 0),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { AuditLogEntryRow, AuthContext, FlockNoteRow } from '../types.js';
|
||||
|
||||
type AuditLogInput = {
|
||||
workspaceId: number;
|
||||
auth?: AuthContext;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
entityName?: string | null;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const createAuditLogEntry = async ({
|
||||
workspaceId,
|
||||
auth,
|
||||
action,
|
||||
entityType,
|
||||
entityId = null,
|
||||
entityName = null,
|
||||
details = {},
|
||||
}: AuditLogInput) => {
|
||||
const result = await db.query<AuditLogEntryRow>(
|
||||
`INSERT INTO audit_log_entries (workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at`,
|
||||
[
|
||||
workspaceId,
|
||||
auth?.user.id ?? null,
|
||||
auth?.user.name ?? null,
|
||||
auth?.user.email ?? null,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
entityName,
|
||||
JSON.stringify(details),
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listAuditLogEntries = async (workspaceId: number, limit = 100) => {
|
||||
const result = await db.query<AuditLogEntryRow>(
|
||||
`SELECT id, workspace_id, user_id, actor_name, actor_email, action, entity_type, entity_id, entity_name, details, created_at
|
||||
FROM audit_log_entries
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[workspaceId, limit],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const listFlockNotes = async (workspaceId: number) => {
|
||||
const result = await db.query<FlockNoteRow>(
|
||||
`SELECT flock_notes.id,
|
||||
flock_notes.workspace_id,
|
||||
flock_notes.bird_id,
|
||||
birds.name AS bird_name,
|
||||
flock_notes.title,
|
||||
flock_notes.body,
|
||||
flock_notes.created_by_user_id,
|
||||
users.name AS created_by_name,
|
||||
flock_notes.created_at,
|
||||
flock_notes.updated_at
|
||||
FROM flock_notes
|
||||
LEFT JOIN birds ON birds.id = flock_notes.bird_id
|
||||
LEFT JOIN users ON users.id = flock_notes.created_by_user_id
|
||||
WHERE flock_notes.workspace_id = $1
|
||||
ORDER BY flock_notes.updated_at DESC`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
export const createFlockNote = async ({
|
||||
workspaceId,
|
||||
birdId,
|
||||
body,
|
||||
createdByUserId,
|
||||
}: {
|
||||
workspaceId: number;
|
||||
birdId: string | null;
|
||||
body: string;
|
||||
createdByUserId: string | null;
|
||||
}) => {
|
||||
const title = body.split(/\s+/).join(' ').slice(0, 160) || 'Note';
|
||||
const result = await db.query<FlockNoteRow>(
|
||||
`WITH inserted_note AS (
|
||||
INSERT INTO flock_notes (workspace_id, bird_id, title, body, created_by_user_id)
|
||||
SELECT $1, $2, $3, $4, $5
|
||||
WHERE $2::uuid IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM birds
|
||||
WHERE birds.id = $2
|
||||
AND birds.workspace_id = $1
|
||||
)
|
||||
RETURNING id, workspace_id, bird_id, title, body, created_by_user_id, created_at, updated_at
|
||||
)
|
||||
SELECT inserted_note.id,
|
||||
inserted_note.workspace_id,
|
||||
inserted_note.bird_id,
|
||||
birds.name AS bird_name,
|
||||
inserted_note.title,
|
||||
inserted_note.body,
|
||||
inserted_note.created_by_user_id,
|
||||
users.name AS created_by_name,
|
||||
inserted_note.created_at,
|
||||
inserted_note.updated_at
|
||||
FROM inserted_note
|
||||
LEFT JOIN birds ON birds.id = inserted_note.bird_id
|
||||
LEFT JOIN users ON users.id = inserted_note.created_by_user_id`,
|
||||
[workspaceId, birdId, title, body, createdByUserId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const deleteFlockNote = async (noteId: string, workspaceId: number) => {
|
||||
const result = await db.query<{ id: string; title: string }>(
|
||||
`DELETE FROM flock_notes
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
RETURNING id, title`,
|
||||
[noteId, workspaceId],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
@@ -33,6 +33,8 @@ const birdSelectFields = `
|
||||
birds.photo_updated_at,
|
||||
birds.notify_on_dob,
|
||||
birds.notify_on_gotcha_day,
|
||||
birds.public_profile_code,
|
||||
birds.public_profile_enabled,
|
||||
birds.memorialized_at,
|
||||
birds.memorialized_on::text,
|
||||
birds.memorial_note,
|
||||
@@ -62,6 +64,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => {
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const getBirdByPublicProfileCode = async (publicProfileCode: string) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`SELECT
|
||||
${birdSelectFields}
|
||||
FROM birds
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT weight_grams, recorded_on
|
||||
FROM weight_records
|
||||
WHERE weight_records.bird_id = birds.id
|
||||
ORDER BY recorded_on DESC
|
||||
LIMIT 1
|
||||
) latest ON TRUE
|
||||
WHERE birds.public_profile_code = $1
|
||||
AND birds.public_profile_enabled = TRUE
|
||||
AND birds.memorialized_at IS NULL`,
|
||||
[publicProfileCode],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
};
|
||||
|
||||
export const listBirds = async (workspaceId: number) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`SELECT
|
||||
@@ -274,6 +297,8 @@ export const createBird = async ({
|
||||
photoUpdatedAt = null,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
publicProfileCode = null,
|
||||
publicProfileEnabled = false,
|
||||
}: {
|
||||
birdId?: string;
|
||||
workspaceId: number;
|
||||
@@ -293,11 +318,13 @@ export const createBird = async ({
|
||||
photoUpdatedAt?: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
publicProfileCode?: string | null;
|
||||
publicProfileEnabled?: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, 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)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
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, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||
[
|
||||
birdId ?? null,
|
||||
workspaceId,
|
||||
@@ -317,6 +344,8 @@ export const createBird = async ({
|
||||
photoUpdatedAt,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
publicProfileCode,
|
||||
publicProfileEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -342,6 +371,8 @@ export const updateBird = async ({
|
||||
photoUpdatedAt = null,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
publicProfileCode,
|
||||
publicProfileEnabled,
|
||||
}: {
|
||||
birdId: string;
|
||||
workspaceId: number;
|
||||
@@ -361,6 +392,8 @@ export const updateBird = async ({
|
||||
photoUpdatedAt?: string | null;
|
||||
notifyOnDob: boolean;
|
||||
notifyOnGotchaDay: boolean;
|
||||
publicProfileCode: string | null;
|
||||
publicProfileEnabled: boolean;
|
||||
}) => {
|
||||
const result = await db.query<BirdRow>(
|
||||
`UPDATE birds
|
||||
@@ -379,11 +412,13 @@ export const updateBird = async ({
|
||||
photo_content_type = $14,
|
||||
photo_updated_at = $15,
|
||||
notify_on_dob = $16,
|
||||
notify_on_gotcha_day = $17
|
||||
notify_on_gotcha_day = $17,
|
||||
public_profile_code = $18,
|
||||
public_profile_enabled = $19
|
||||
WHERE id = $1
|
||||
AND workspace_id = $18
|
||||
AND workspace_id = $20
|
||||
AND memorialized_at IS NULL
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||
(
|
||||
SELECT weight_grams::text
|
||||
FROM weight_records
|
||||
@@ -416,6 +451,8 @@ export const updateBird = async ({
|
||||
photoUpdatedAt,
|
||||
notifyOnDob,
|
||||
notifyOnGotchaDay,
|
||||
publicProfileCode,
|
||||
publicProfileEnabled,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createWorkspace,
|
||||
deleteWorkspaceIfEmpty,
|
||||
ensureDefaultWorkspaceForUser,
|
||||
ensurePersonalWorkspaceForUser,
|
||||
findAlternateWorkspaceForUser,
|
||||
getPlatformAdminSummary,
|
||||
@@ -64,6 +65,53 @@ test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claimi
|
||||
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
|
||||
});
|
||||
|
||||
test('ensureDefaultWorkspaceForUser reuses an existing rescue workspace without creating a household flock', async () => {
|
||||
const { calls } = mockDb({
|
||||
rowCount: 1,
|
||||
rows: [{ workspace_id: 84 }],
|
||||
});
|
||||
|
||||
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
||||
|
||||
assert.equal(workspaceId, 84);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].text, /FROM workspace_members/);
|
||||
assert.doesNotMatch(calls[0].text, /workspaces\.workspace_type = 'standard'/);
|
||||
});
|
||||
|
||||
test('ensureDefaultWorkspaceForUser creates a household flock when the user has no workspace', async () => {
|
||||
const { calls } = mockDb(
|
||||
{
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
},
|
||||
{
|
||||
rowCount: 0,
|
||||
rows: [],
|
||||
},
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [{ next_id: 43 }],
|
||||
},
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [],
|
||||
},
|
||||
{
|
||||
rowCount: 1,
|
||||
rows: [],
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
||||
|
||||
assert.equal(workspaceId, 43);
|
||||
assert.equal(calls.length, 5);
|
||||
assert.match(calls[0].text, /FROM workspace_members/);
|
||||
assert.match(calls[1].text, /workspaces\.workspace_type = 'standard'/);
|
||||
assert.match(calls[3].text, /INSERT INTO workspaces/);
|
||||
});
|
||||
|
||||
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
||||
const { calls } = mockDb(
|
||||
{ rowCount: 1, rows: [] },
|
||||
|
||||
@@ -114,6 +114,24 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
||||
return workspaceId;
|
||||
};
|
||||
|
||||
export const ensureDefaultWorkspaceForUser = async (user: UserRow) => {
|
||||
const existing = await db.query<{ workspace_id: number }>(
|
||||
`SELECT workspace_id
|
||||
FROM workspace_members
|
||||
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
||||
WHERE workspace_members.user_id = $1
|
||||
ORDER BY workspaces.created_at ASC
|
||||
LIMIT 1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
if (existing.rowCount) {
|
||||
return Number(existing.rows[0].workspace_id);
|
||||
}
|
||||
|
||||
return ensurePersonalWorkspaceForUser(user);
|
||||
};
|
||||
|
||||
export const claimWorkspaceInvites = async (user: UserRow) => {
|
||||
await db.query(
|
||||
`UPDATE workspace_members
|
||||
@@ -362,7 +380,6 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
|
||||
export const listRescueWorkspacesForAdmin = async () => {
|
||||
const result = await db.query<
|
||||
WorkspaceRow & {
|
||||
owner_email: string | null;
|
||||
bird_count: number;
|
||||
member_count: number;
|
||||
}
|
||||
@@ -380,17 +397,13 @@ export const listRescueWorkspacesForAdmin = async () => {
|
||||
workspaces.rescue_verification_status,
|
||||
workspaces.created_at,
|
||||
workspaces.updated_at,
|
||||
owner.invite_email AS owner_email,
|
||||
COUNT(DISTINCT birds.id)::int AS bird_count,
|
||||
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
||||
FROM workspaces
|
||||
LEFT JOIN workspace_members owner
|
||||
ON owner.workspace_id = workspaces.id
|
||||
AND owner.role = 'owner'
|
||||
LEFT JOIN birds ON birds.workspace_id = workspaces.id
|
||||
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
||||
WHERE workspaces.workspace_type = 'rescue'
|
||||
GROUP BY workspaces.id, owner.invite_email
|
||||
GROUP BY workspaces.id
|
||||
ORDER BY
|
||||
CASE workspaces.rescue_verification_status
|
||||
WHEN 'pending' THEN 0
|
||||
|
||||
+30
-1
@@ -1,6 +1,6 @@
|
||||
export type WorkspaceType = 'standard' | 'rescue';
|
||||
export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer';
|
||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||
export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw';
|
||||
export type BillingInterval = 'monthly' | 'yearly';
|
||||
export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none';
|
||||
export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected';
|
||||
@@ -111,6 +111,8 @@ export type BirdRow = {
|
||||
photo_updated_at: string | null;
|
||||
notify_on_dob: boolean;
|
||||
notify_on_gotcha_day: boolean;
|
||||
public_profile_code: string | null;
|
||||
public_profile_enabled: boolean;
|
||||
memorialized_at: string | null;
|
||||
memorialized_on: string | null;
|
||||
memorial_note: string | null;
|
||||
@@ -204,6 +206,33 @@ export type MedicationAdministrationRow = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FlockNoteRow = {
|
||||
id: string;
|
||||
workspace_id: number;
|
||||
bird_id: string | null;
|
||||
bird_name: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
created_by_user_id: string | null;
|
||||
created_by_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AuditLogEntryRow = {
|
||||
id: string;
|
||||
workspace_id: number;
|
||||
user_id: string | null;
|
||||
actor_name: string | null;
|
||||
actor_email: string | null;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
entity_name: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuthContext = {
|
||||
user: UserRow;
|
||||
session: AuthSessionRow;
|
||||
|
||||
@@ -73,9 +73,12 @@ services:
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
|
||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success}
|
||||
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled}
|
||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal}
|
||||
|
||||
+6
-3
@@ -71,9 +71,12 @@ services:
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-}
|
||||
STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-}
|
||||
STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success}
|
||||
STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled}
|
||||
STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal}
|
||||
|
||||
@@ -653,7 +653,7 @@ Request body:
|
||||
Notes:
|
||||
|
||||
- `workspaceType` must be `standard` or `rescue`
|
||||
- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw`
|
||||
- `billingPlan` may be `household_basic`, `household_plus`, `household_macaw`, or `household_hyacinth_macaw`
|
||||
- rescue workspaces are forced to `rescue_free`
|
||||
|
||||
Response `201`:
|
||||
|
||||
Generated
+497
-1
@@ -8,8 +8,11 @@
|
||||
"name": "flockpal-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"read-excel-file": "^9.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
@@ -1144,6 +1147,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1151,6 +1163,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||
@@ -1192,6 +1213,39 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.9.10",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.16",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
||||
@@ -1205,6 +1259,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -1239,6 +1299,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001786",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||
@@ -1260,6 +1329,35 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1267,6 +1365,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1292,6 +1396,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.331",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
||||
@@ -1299,6 +1427,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1348,6 +1482,39 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1373,6 +1540,42 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1405,6 +1608,30 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1453,6 +1680,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.37",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
||||
@@ -1460,6 +1693,51 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1467,6 +1745,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -1496,6 +1783,29 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1531,6 +1841,50 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-excel-file": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz",
|
||||
"integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.9",
|
||||
"fflate": "^0.8.2",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
@@ -1576,6 +1930,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -1595,6 +1955,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1605,6 +1971,41 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
@@ -1619,6 +2020,34 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bluebird": "~3.7.2",
|
||||
"duplexer2": "~0.1.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graceful-fs": "^4.2.2",
|
||||
"node-int64": "^0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -1650,6 +2079,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
||||
@@ -1710,12 +2145,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"read-excel-file": "^9.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.12",
|
||||
|
||||
+1877
-263
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
+513
-5
@@ -122,6 +122,70 @@ textarea {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.top-alert-notification {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(203, 58, 53, 0.26);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 247, 244, 0.98), rgba(255, 238, 231, 0.96));
|
||||
box-shadow: 0 16px 30px rgba(203, 58, 53, 0.14);
|
||||
}
|
||||
|
||||
.top-alert-notification div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.top-alert-notification strong {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.top-alert-notification span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: rgba(203, 58, 53, 0.12);
|
||||
border: 1px solid rgba(203, 58, 53, 0.22);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 7px;
|
||||
width: 12px;
|
||||
height: 15px;
|
||||
border: 2px solid var(--accent-red);
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 4px 4px;
|
||||
}
|
||||
|
||||
.notification-bell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 22px;
|
||||
width: 10px;
|
||||
height: 5px;
|
||||
border-top: 2px solid var(--accent-red);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.top-alert-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
@@ -155,6 +219,53 @@ textarea {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.public-profile-shell {
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.public-profile-card {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-profile-logo {
|
||||
width: min(220px, 70%);
|
||||
height: auto;
|
||||
filter: drop-shadow(0 10px 18px rgba(86, 63, 34, 0.12));
|
||||
}
|
||||
|
||||
.public-profile-logo-link {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.public-profile-photo {
|
||||
width: min(260px, 100%);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(39, 105, 179, 0.16);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.public-profile-copy {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.public-profile-copy h1 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.auth-hero-card {
|
||||
min-height: 280px;
|
||||
align-items: end;
|
||||
@@ -509,6 +620,10 @@ textarea {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.settings-card-bird-profiles[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-card-collaborators {
|
||||
order: 2;
|
||||
}
|
||||
@@ -517,10 +632,14 @@ textarea {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.settings-card-transfer {
|
||||
.settings-card-bird-import {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.settings-card-transfer {
|
||||
order: 6;
|
||||
}
|
||||
|
||||
.settings-card-flock-profile {
|
||||
order: 1;
|
||||
}
|
||||
@@ -563,6 +682,21 @@ textarea {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.import-column-guide {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 254, 250, 0.72);
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.import-preview-list {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-danger-card {
|
||||
border-color: rgba(203, 58, 53, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
||||
@@ -579,6 +713,11 @@ textarea {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bird-detail-panel {
|
||||
margin-right: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flock-detail-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
@@ -617,6 +756,18 @@ textarea {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.member-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-header-actions .bird-alert-stack {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.billing-inline-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -892,6 +1043,17 @@ textarea {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.latest-weight-callout rect {
|
||||
fill: rgba(255, 253, 249, 0.94);
|
||||
stroke: rgba(31, 42, 42, 0.16);
|
||||
}
|
||||
|
||||
.latest-weight-callout text {
|
||||
fill: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.historical-weight-line,
|
||||
.historical-weight-dot {
|
||||
opacity: 0.48;
|
||||
@@ -922,6 +1084,35 @@ textarea {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.note-card,
|
||||
.audit-log-card {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.note-card div,
|
||||
.audit-log-card div {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.note-card span,
|
||||
.audit-log-card span,
|
||||
.audit-log-card small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.note-card p {
|
||||
margin: 0.35rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.note-card .button-row {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.legend-grid,
|
||||
.detail-grid,
|
||||
.summary-grid {
|
||||
@@ -933,10 +1124,117 @@ textarea {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
padding: 1rem 6.4rem 1rem 1rem;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
||||
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.profile-icon-button {
|
||||
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);
|
||||
}
|
||||
|
||||
.profile-icon-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(35, 138, 90, 0.34);
|
||||
}
|
||||
|
||||
.profile-icon-button svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: none;
|
||||
stroke: var(--accent-blue);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.qr-profile-button svg {
|
||||
fill: var(--accent-blue);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.bird-detail-tabs {
|
||||
position: absolute;
|
||||
top: 5rem;
|
||||
right: -3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bird-detail-tab {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(39, 105, 179, 0.14);
|
||||
border-left: 0;
|
||||
border-radius: 0 14px 14px 0;
|
||||
background: rgba(255, 254, 250, 0.92);
|
||||
color: var(--muted);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.bird-detail-tab svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.bird-detail-tab .weight-tab-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.bird-detail-tab .info-tab-icon,
|
||||
.bird-detail-tab .note-tab-icon,
|
||||
.bird-detail-tab .audit-tab-icon,
|
||||
.bird-detail-tab .vet-tab-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.bird-detail-tab:hover {
|
||||
border-color: rgba(35, 138, 90, 0.28);
|
||||
color: var(--ink);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bird-detail-tab.active {
|
||||
border-color: rgba(35, 138, 90, 0.42);
|
||||
background: rgba(240, 248, 244, 0.95);
|
||||
color: var(--accent-green);
|
||||
box-shadow: 10px 10px 20px rgba(39, 105, 179, 0.1);
|
||||
}
|
||||
|
||||
.bird-detail-tab-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.profile-copy {
|
||||
@@ -949,8 +1247,7 @@ textarea {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.profile-title,
|
||||
.detail-gender {
|
||||
.profile-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -1048,6 +1345,25 @@ textarea {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-inline-header {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.settings-inline-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-list-fields {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.profile-list-fields input {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.care-form-actions {
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
@@ -1116,6 +1432,21 @@ textarea {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.billing-contact-email {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-item-list {
|
||||
margin: 0;
|
||||
padding-left: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-item-list li + li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.summary-list {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
@@ -1360,6 +1691,21 @@ label {
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding-top: 1.75rem;
|
||||
}
|
||||
|
||||
.toggle-field input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
@@ -1544,11 +1890,79 @@ label {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.qr-modal {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.qr-print-card {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
border-radius: 22px;
|
||||
background: #fffdf9;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: min(280px, 100%);
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.qr-bird-mark rect {
|
||||
fill: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.qr-print-card h3,
|
||||
.qr-print-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-print-card p {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.modal-alert-list {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body::before,
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-modal-backdrop {
|
||||
position: static;
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app-modal {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.qr-print-card {
|
||||
min-height: 100vh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell,
|
||||
.auth-panel,
|
||||
@@ -1566,6 +1980,7 @@ label {
|
||||
|
||||
.app-shell {
|
||||
padding: 1rem;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
@@ -1577,11 +1992,104 @@ label {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
.top-alert-notification {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.top-alert-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.bird-detail-panel {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.profile-hero {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bird-detail-tabs {
|
||||
position: static;
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.bird-detail-tab {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
border-left: 1px solid rgba(39, 105, 179, 0.14);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
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