Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a502966293 | |||
| b7186528c5 | |||
| 49d75f34be | |||
| df3fcbf885 | |||
| 4715306d14 | |||
| 62afc94f2f | |||
| e6211d7f5e | |||
| cf3cd96384 | |||
| 38dcb7f49b | |||
| 1c0d57299d | |||
| f2c506ec16 | |||
| 7514c7c306 | |||
| 0db90aab45 | |||
| 6dbe51410c | |||
| ac1afc613f | |||
| 01541c5f5c | |||
| fc6d7c2762 | |||
| 22f344a998 | |||
| 1bb3002baf |
@@ -2,6 +2,15 @@ POSTGRES_DB=flockpal
|
|||||||
POSTGRES_USER=flockpal
|
POSTGRES_USER=flockpal
|
||||||
POSTGRES_PASSWORD=change_me_for_production
|
POSTGRES_PASSWORD=change_me_for_production
|
||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
|
IMAGE_STORAGE_PROVIDER=database
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
S3_PUBLIC_BASE_URL=
|
||||||
|
S3_KEY_PREFIX=bird-photos
|
||||||
|
PHOTO_DELIVERY_MODE=proxy
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
BACKEND_URL=http://localhost:5000
|
BACKEND_URL=http://localhost:5000
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
VITE_API_BASE_URL=http://localhost:5000/api
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ frontend/dist
|
|||||||
data/
|
data/
|
||||||
backups/
|
backups/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
docker-compose.dev.yaml
|
||||||
@@ -87,6 +87,12 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
|
|||||||
- `BACKEND_URL`
|
- `BACKEND_URL`
|
||||||
- `VITE_API_BASE_URL`
|
- `VITE_API_BASE_URL`
|
||||||
- `REDIS_URL`
|
- `REDIS_URL`
|
||||||
|
- `IMAGE_STORAGE_PROVIDER`
|
||||||
|
- `S3_ENDPOINT`
|
||||||
|
- `S3_REGION`
|
||||||
|
- `S3_BUCKET`
|
||||||
|
- `S3_ACCESS_KEY_ID`
|
||||||
|
- `S3_SECRET_ACCESS_KEY`
|
||||||
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
||||||
2. Build and start the production stack:
|
2. Build and start the production stack:
|
||||||
|
|
||||||
@@ -104,6 +110,37 @@ Compose includes a Redis service at `redis://redis:6379` and passes that value t
|
|||||||
|
|
||||||
Scheduled milestone reminders are enqueued through Redis with a per-date job id, then processed by the worker. This keeps scheduled work out of API containers and prevents duplicate scheduled jobs when the API is scaled horizontally. Redis can also support later shared rate-limit state and short-lived cache entries.
|
Scheduled milestone reminders are enqueued through Redis with a per-date job id, then processed by the worker. This keeps scheduled work out of API containers and prevents duplicate scheduled jobs when the API is scaled horizontally. Redis can also support later shared rate-limit state and short-lived cache entries.
|
||||||
|
|
||||||
|
## Image storage
|
||||||
|
|
||||||
|
FlockPal currently keeps bird photos in Postgres as `photo_data_url`. The schema also has S3 object metadata columns so image storage can move to Wasabi/S3 without changing the bird record contract.
|
||||||
|
|
||||||
|
Set these when Wasabi image storage is ready:
|
||||||
|
|
||||||
|
- `IMAGE_STORAGE_PROVIDER=s3`
|
||||||
|
- `S3_ENDPOINT=https://s3.<wasabi-region>.wasabisys.com`
|
||||||
|
- `S3_REGION=<wasabi-region>`
|
||||||
|
- `S3_BUCKET=<bucket-name>`
|
||||||
|
- `S3_ACCESS_KEY_ID=<access-key>`
|
||||||
|
- `S3_SECRET_ACCESS_KEY=<secret-key>`
|
||||||
|
- `S3_PUBLIC_BASE_URL=<optional CDN or public bucket base URL; leave blank for private signed URLs>`
|
||||||
|
- `S3_KEY_PREFIX=bird-photos`
|
||||||
|
- `PHOTO_DELIVERY_MODE=proxy`
|
||||||
|
|
||||||
|
Use a dedicated private bucket and access key for FlockPal images. Grant only the S3 permissions the app needs for that bucket. When `S3_PUBLIC_BASE_URL` is blank, FlockPal stores private object keys. `PHOTO_DELIVERY_MODE=proxy` streams images through the backend after validating the app photo token; `PHOTO_DELIVERY_MODE=redirect` validates the app token and redirects to a short-lived Wasabi signed URL.
|
||||||
|
|
||||||
|
Migrate existing Postgres-stored bird photos after deploying S3 image storage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml exec backend npm run migrate:bird-photos-to-s3 -- --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a dry run first by omitting `--apply`. Use `--limit=10` to migrate a small batch, and `--keep-data-url` if you want to leave the original inline image in Postgres during an initial verification pass.
|
||||||
|
|
||||||
|
Bucket settings recommendation:
|
||||||
|
|
||||||
|
- Enable bucket versioning if you want rollback protection from accidental overwrites or deletes. Add a lifecycle policy once upload volume is known because every object version contributes to stored data.
|
||||||
|
- Do not enable Object Lock on the primary app image bucket unless there is a strict legal/compliance retention requirement. Object Lock must be enabled when creating the bucket, depends on versioning, and can make user-requested image deletion or replacement harder.
|
||||||
|
|
||||||
## Worker process
|
## Worker process
|
||||||
|
|
||||||
The API container does not run scheduled reminder loops. Background reminders run in the `worker` service so the API can be scaled horizontally without multiple API containers sending duplicate scheduled emails.
|
The API container does not run scheduled reminder loops. Background reminders run in the `worker` service so the API can be scaled horizontally without multiple API containers sending duplicate scheduled emails.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"worker:dev": "tsx watch src/worker.ts",
|
"worker:dev": "tsx watch src/worker.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "tsx --test src/**/*.test.ts",
|
"test": "tsx --test src/**/*.test.ts",
|
||||||
|
"migrate:bird-photos-to-s3": "node dist/scripts/migrateBirdPhotosToS3.js",
|
||||||
|
"migrate:bird-photos-to-s3:dev": "tsx src/scripts/migrateBirdPhotosToS3.ts",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"worker": "node dist/worker.js"
|
"worker": "node dist/worker.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+612
-27
@@ -44,6 +44,7 @@ import {
|
|||||||
deleteMedicationForBird,
|
deleteMedicationForBird,
|
||||||
deleteVetVisitForBird,
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
|
getBirdByPublicProfileCode,
|
||||||
listBirds,
|
listBirds,
|
||||||
listDueBirdMilestoneReminders,
|
listDueBirdMilestoneReminders,
|
||||||
listMemorializedBirds,
|
listMemorializedBirds,
|
||||||
@@ -60,12 +61,33 @@ import {
|
|||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
|
import {
|
||||||
|
deleteDailyEducation,
|
||||||
|
deleteEducationQuestion,
|
||||||
|
createEducationQuestion,
|
||||||
|
getDailyEducationForDate,
|
||||||
|
getEducationOptOut,
|
||||||
|
listDailyEducationForAdmin,
|
||||||
|
listDailyEducationQuestions,
|
||||||
|
listEducationQuestionsForAdmin,
|
||||||
|
updateEducationOptOut,
|
||||||
|
updateEducationQuestion,
|
||||||
|
upsertDailyEducation,
|
||||||
|
} from './repositories/educationRepository.js';
|
||||||
|
import {
|
||||||
|
buildBirdPhotoObjectKey,
|
||||||
|
getImageExtensionFromContentType,
|
||||||
|
getImageStorageProvider,
|
||||||
|
getS3ImageStorageConfig,
|
||||||
|
} from './storage/imageStorageConfig.js';
|
||||||
|
import { deleteS3Object, getSignedS3ObjectUrl, putS3Object } from './storage/s3Client.js';
|
||||||
import {
|
import {
|
||||||
cancelRescueVerificationRequest,
|
cancelRescueVerificationRequest,
|
||||||
claimWorkspaceInvites,
|
claimWorkspaceInvites,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceMember,
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
findAlternateWorkspaceForUser,
|
findAlternateWorkspaceForUser,
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
@@ -89,6 +111,8 @@ import type {
|
|||||||
AuthContext,
|
AuthContext,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
|
DailyEducationRow,
|
||||||
|
EducationQuestionRow,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
@@ -127,6 +151,7 @@ const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
|||||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||||
|
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||||
|
|
||||||
if (trustProxy) {
|
if (trustProxy) {
|
||||||
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
||||||
@@ -160,6 +185,7 @@ const photoDataUrlSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
|
.regex(/^data:image\/(?:png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/)
|
||||||
.max(1_500_000);
|
.max(1_500_000);
|
||||||
|
const photoUrlSchema = z.string().trim().url().max(2000);
|
||||||
|
|
||||||
const magicLinkRequestSchema = z.object({
|
const magicLinkRequestSchema = z.object({
|
||||||
name: z.string().trim().max(160).optional().or(z.literal('')),
|
name: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
@@ -222,17 +248,33 @@ const lostBirdReportSchema = z.object({
|
|||||||
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
message: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const publicProfileCodeSchema = z.string().trim().regex(/^[A-Za-z0-9_-]{8,32}$/);
|
||||||
|
const birdProfileListSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(1000)
|
||||||
|
.refine(
|
||||||
|
(value) => value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).length <= 3,
|
||||||
|
'Use no more than three list items.',
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''));
|
||||||
|
|
||||||
const birdSchema = z.object({
|
const birdSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
tagId: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
species: z.string().trim().min(1).max(120),
|
species: z.string().trim().min(1).max(120),
|
||||||
|
motivators: birdProfileListSchema,
|
||||||
|
demotivators: birdProfileListSchema,
|
||||||
|
favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')),
|
||||||
gender: birdGenderSchema.optional(),
|
gender: birdGenderSchema.optional(),
|
||||||
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
dateOfBirth: dateStringSchema.optional().or(z.literal('')),
|
||||||
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
gotchaDay: dateStringSchema.optional().or(z.literal('')),
|
||||||
chartColor: chartColorSchema.optional(),
|
chartColor: chartColorSchema.optional(),
|
||||||
photoDataUrl: photoDataUrlSchema.optional().or(z.literal('')),
|
photoDataUrl: z.union([photoDataUrlSchema, photoUrlSchema, z.literal('')]).optional(),
|
||||||
notifyOnDob: z.boolean().optional(),
|
notifyOnDob: z.boolean().optional(),
|
||||||
notifyOnGotchaDay: z.boolean().optional(),
|
notifyOnGotchaDay: z.boolean().optional(),
|
||||||
|
publicProfileEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const memorializeBirdSchema = z.object({
|
const memorializeBirdSchema = z.object({
|
||||||
@@ -296,6 +338,27 @@ const integrationTokenCreateSchema = z.object({
|
|||||||
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const educationQuestionSchema = z
|
||||||
|
.object({
|
||||||
|
prompt: z.string().trim().min(1).max(500),
|
||||||
|
options: z.array(z.string().trim().min(1).max(240)).min(2).max(4),
|
||||||
|
correctAnswerIndex: z.coerce.number().int().min(0).max(3),
|
||||||
|
explanation: z.string().trim().max(800).optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
.refine((value) => value.correctAnswerIndex < value.options.length, {
|
||||||
|
message: 'Correct answer must match one of the quiz options.',
|
||||||
|
path: ['correctAnswerIndex'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyEducationSchema = z.object({
|
||||||
|
publishDate: dateStringSchema,
|
||||||
|
fact: z.string().trim().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const educationPreferenceSchema = z.object({
|
||||||
|
educationOptOut: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
const emptyToNull = (value?: string) => {
|
const emptyToNull = (value?: string) => {
|
||||||
const trimmed = value?.trim() ?? '';
|
const trimmed = value?.trim() ?? '';
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
@@ -311,7 +374,9 @@ const normalizeBandId = (value?: string | null) => {
|
|||||||
const normalizeEmail = (value: string) => value.trim().toLowerCase();
|
const normalizeEmail = (value: string) => value.trim().toLowerCase();
|
||||||
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
const hashToken = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
|
||||||
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
const createSessionToken = () => crypto.randomBytes(32).toString('hex');
|
||||||
|
const photoAccessSecret = process.env.PHOTO_ACCESS_SECRET?.trim() || process.env.S3_SECRET_ACCESS_KEY?.trim() || createSessionToken();
|
||||||
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
const createIntegrationToken = () => `flpt_${crypto.randomBytes(24).toString('hex')}`;
|
||||||
|
const createPublicProfileCode = () => crypto.randomBytes(9).toString('base64url');
|
||||||
const createRandomId = () => crypto.randomUUID();
|
const createRandomId = () => crypto.randomUUID();
|
||||||
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
const createCodeVerifier = () => crypto.randomBytes(32).toString('base64url');
|
||||||
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||||
@@ -449,13 +514,11 @@ const normalizeWorkspace = (row: WorkspaceRow) => ({
|
|||||||
|
|
||||||
const normalizeAdminRescueWorkspace = (
|
const normalizeAdminRescueWorkspace = (
|
||||||
row: WorkspaceRow & {
|
row: WorkspaceRow & {
|
||||||
owner_email: string | null;
|
|
||||||
bird_count: number;
|
bird_count: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
},
|
},
|
||||||
) => ({
|
) => ({
|
||||||
workspace: normalizeWorkspace(row),
|
workspace: normalizeWorkspace(row),
|
||||||
ownerEmail: row.owner_email,
|
|
||||||
birdCount: Number(row.bird_count ?? 0),
|
birdCount: Number(row.bird_count ?? 0),
|
||||||
memberCount: Number(row.member_count ?? 0),
|
memberCount: Number(row.member_count ?? 0),
|
||||||
});
|
});
|
||||||
@@ -471,19 +534,122 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeEducationQuestion = (row: EducationQuestionRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
prompt: row.prompt,
|
||||||
|
options: row.options,
|
||||||
|
correctAnswerIndex: Number(row.correct_answer_index),
|
||||||
|
explanation: row.explanation ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({
|
||||||
|
id: row.id,
|
||||||
|
publishDate: row.publish_date,
|
||||||
|
fact: row.fact,
|
||||||
|
quizQuestions: questions.map(normalizeEducationQuestion),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signBirdPhotoAccessToken = (row: BirdRow) => {
|
||||||
|
if (!row.photo_object_key) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60;
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
birdId: row.id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
objectKey: row.photo_object_key,
|
||||||
|
expiresAt,
|
||||||
|
}),
|
||||||
|
).toString('base64url');
|
||||||
|
const signature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url');
|
||||||
|
|
||||||
|
return `${payload}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyBirdPhotoAccessToken = (token: string) => {
|
||||||
|
const [payload, signature] = token.split('.');
|
||||||
|
|
||||||
|
if (!payload || !signature) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSignature = crypto.createHmac('sha256', photoAccessSecret).update(payload).digest('base64url');
|
||||||
|
|
||||||
|
const signatureBuffer = Buffer.from(signature);
|
||||||
|
const expectedSignatureBuffer = Buffer.from(expectedSignature);
|
||||||
|
|
||||||
|
if (signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
||||||
|
birdId?: unknown;
|
||||||
|
workspaceId?: unknown;
|
||||||
|
objectKey?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.birdId !== 'string' ||
|
||||||
|
typeof parsed.workspaceId !== 'number' ||
|
||||||
|
typeof parsed.objectKey !== 'string' ||
|
||||||
|
typeof parsed.expiresAt !== 'number' ||
|
||||||
|
parsed.expiresAt < Math.floor(Date.now() / 1000)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
objectKey: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBirdPhotoUrl = (row: BirdRow) => {
|
||||||
|
if (!row.photo_object_key) {
|
||||||
|
return row.photo_data_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
return row.photo_data_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoUrl = new URL(`${backendBaseUrl}/api/birds/${row.id}/photo`);
|
||||||
|
photoUrl.searchParams.set('token', signBirdPhotoAccessToken(row));
|
||||||
|
return photoUrl.toString();
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeBird = (row: BirdRow) => ({
|
const normalizeBird = (row: BirdRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
workspaceId: row.workspace_id,
|
workspaceId: row.workspace_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
tagId: normalizeBandId(row.tag_id),
|
tagId: normalizeBandId(row.tag_id),
|
||||||
species: row.species,
|
species: row.species,
|
||||||
|
motivators: row.motivators,
|
||||||
|
demotivators: row.demotivators,
|
||||||
|
favoriteSnack: row.favorite_snack,
|
||||||
gender: row.gender,
|
gender: row.gender,
|
||||||
dateOfBirth: row.date_of_birth,
|
dateOfBirth: row.date_of_birth,
|
||||||
gotchaDay: row.gotcha_day,
|
gotchaDay: row.gotcha_day,
|
||||||
chartColor: row.chart_color,
|
chartColor: row.chart_color,
|
||||||
photoDataUrl: row.photo_data_url,
|
photoDataUrl: getBirdPhotoUrl(row),
|
||||||
|
photoObjectKey: row.photo_object_key,
|
||||||
|
photoContentType: row.photo_content_type,
|
||||||
|
photoUpdatedAt: row.photo_updated_at,
|
||||||
notifyOnDob: row.notify_on_dob,
|
notifyOnDob: row.notify_on_dob,
|
||||||
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
notifyOnGotchaDay: row.notify_on_gotcha_day,
|
||||||
|
publicProfileCode: row.public_profile_code ?? null,
|
||||||
|
publicProfileEnabled: row.public_profile_enabled ?? false,
|
||||||
memorializedAt: row.memorialized_at,
|
memorializedAt: row.memorialized_at,
|
||||||
memorializedOn: row.memorialized_on,
|
memorializedOn: row.memorialized_on,
|
||||||
memorialNote: row.memorial_note,
|
memorialNote: row.memorial_note,
|
||||||
@@ -493,6 +659,16 @@ const normalizeBird = (row: BirdRow) => ({
|
|||||||
latestRecordedOn: row.latest_recorded_on,
|
latestRecordedOn: row.latest_recorded_on,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizePublicBirdProfile = (row: BirdRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
workspaceId: row.workspace_id,
|
||||||
|
name: row.name,
|
||||||
|
favoriteSnack: row.favorite_snack,
|
||||||
|
gender: row.gender,
|
||||||
|
dateOfBirth: row.date_of_birth,
|
||||||
|
photoDataUrl: getBirdPhotoUrl(row),
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeWeight = (row: WeightRow) => ({
|
const normalizeWeight = (row: WeightRow) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
birdId: row.bird_id,
|
birdId: row.bird_id,
|
||||||
@@ -827,6 +1003,21 @@ const syncWorkspaceStripeBilling = async (workspaceId: number) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelWorkspaceStripeSubscription = async (workspace: WorkspaceRow) => {
|
||||||
|
if (workspace.workspace_type === 'rescue' || (!workspace.stripe_subscription_id && !workspace.stripe_customer_id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await getMostRelevantStripeSubscriptionForWorkspace(workspace);
|
||||||
|
|
||||||
|
if (!subscription || subscription.status === 'canceled') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getStripeClient().subscriptions.cancel(subscription.id);
|
||||||
|
return subscription.id;
|
||||||
|
};
|
||||||
|
|
||||||
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
const getStripePriceIdForBillingPlan = (billingPlan: BillingPlan, billingInterval: BillingInterval) => {
|
||||||
if (billingPlan === 'rescue_free') {
|
if (billingPlan === 'rescue_free') {
|
||||||
throw new Error('Rescue flocks do not use Stripe billing.');
|
throw new Error('Rescue flocks do not use Stripe billing.');
|
||||||
@@ -1018,6 +1209,107 @@ const parseDataImage = (dataUrl: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDataImageUrl = (value: string | null | undefined) => Boolean(value && value.startsWith('data:image/'));
|
||||||
|
|
||||||
|
const resolveBirdPhotoStorage = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
photoDataUrl,
|
||||||
|
existingBird,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
photoDataUrl: string | null;
|
||||||
|
existingBird?: BirdRow | null;
|
||||||
|
}) => {
|
||||||
|
if (!photoDataUrl) {
|
||||||
|
return {
|
||||||
|
photoDataUrl: null,
|
||||||
|
photoObjectKey: null,
|
||||||
|
photoContentType: null,
|
||||||
|
photoUpdatedAt: null,
|
||||||
|
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDataImageUrl(photoDataUrl)) {
|
||||||
|
if (existingBird?.photo_object_key) {
|
||||||
|
return {
|
||||||
|
photoDataUrl: null,
|
||||||
|
photoObjectKey: existingBird.photo_object_key,
|
||||||
|
photoContentType: existingBird.photo_content_type,
|
||||||
|
photoUpdatedAt: existingBird.photo_updated_at,
|
||||||
|
objectKeyToDelete: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
photoDataUrl,
|
||||||
|
photoObjectKey: null,
|
||||||
|
photoContentType: null,
|
||||||
|
photoUpdatedAt: null,
|
||||||
|
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedImage = parseDataImage(photoDataUrl);
|
||||||
|
|
||||||
|
if (!parsedImage) {
|
||||||
|
throw new Error('Unable to process bird photo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getImageStorageProvider() !== 's3') {
|
||||||
|
return {
|
||||||
|
photoDataUrl,
|
||||||
|
photoObjectKey: null,
|
||||||
|
photoContentType: null,
|
||||||
|
photoUpdatedAt: null,
|
||||||
|
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
throw new Error('S3 image storage is enabled but not fully configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = getImageExtensionFromContentType(parsedImage.contentType);
|
||||||
|
const objectKey = buildBirdPhotoObjectKey({ workspaceId, birdId, extension });
|
||||||
|
await putS3Object({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey,
|
||||||
|
content: parsedImage.content,
|
||||||
|
contentType: parsedImage.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
photoDataUrl: null,
|
||||||
|
photoObjectKey: objectKey,
|
||||||
|
photoContentType: parsedImage.contentType,
|
||||||
|
photoUpdatedAt: new Date().toISOString(),
|
||||||
|
objectKeyToDelete: existingBird?.photo_object_key ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBirdPhotoObjectIfNeeded = async (objectKey: string | null) => {
|
||||||
|
if (!objectKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteS3Object({ config: s3Config, objectKey });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Unable to delete old bird photo object ${objectKey}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getDefaultBirdPhotoAttachment = () => {
|
const getDefaultBirdPhotoAttachment = () => {
|
||||||
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
|
||||||
|
|
||||||
@@ -1038,10 +1330,12 @@ const sendRescueStatusNotification = async ({
|
|||||||
workspace,
|
workspace,
|
||||||
ownerEmail,
|
ownerEmail,
|
||||||
event,
|
event,
|
||||||
|
note,
|
||||||
}: {
|
}: {
|
||||||
workspace: WorkspaceRow;
|
workspace: WorkspaceRow;
|
||||||
ownerEmail: string | null;
|
ownerEmail: string | null;
|
||||||
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
event: 'created' | 'converted' | 'status_changed' | 'canceled';
|
||||||
|
note?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
const statusLabel = workspace.rescue_verification_status.replace(/_/g, ' ');
|
||||||
const eventLabel =
|
const eventLabel =
|
||||||
@@ -1065,6 +1359,11 @@ const sendRescueStatusNotification = async ({
|
|||||||
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
`Billing email: ${workspace.billing_email ?? 'not set'}`,
|
||||||
`Flock ID: ${workspace.id}`,
|
`Flock ID: ${workspace.id}`,
|
||||||
];
|
];
|
||||||
|
const escapedNote = note ? escapeHtml(note) : null;
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
lines.push(`Note: ${note}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mailTransport) {
|
if (!mailTransport) {
|
||||||
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
console.log(`Rescue status notification for ${rescueStatusNotificationEmail}:\n${lines.join('\n')}`);
|
||||||
@@ -1085,6 +1384,7 @@ const sendRescueStatusNotification = async ({
|
|||||||
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
|
<li><strong>Billing email:</strong> ${escapedBillingEmail}</li>
|
||||||
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1139,6 +1439,17 @@ const sendRescueOnboardingWebhook = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const trySendRescueOnboardingWebhook = async (payload: Parameters<typeof sendRescueOnboardingWebhook>[0]) => {
|
||||||
|
try {
|
||||||
|
await sendRescueOnboardingWebhook(payload);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown rescue onboarding webhook error.';
|
||||||
|
console.error(`Rescue onboarding webhook failed for workspace ${payload.workspaceId}:`, error);
|
||||||
|
return `The rescue onboarding webhook failed and this rescue requires manual review. ${errorMessage}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const issueMagicLinkInvite = async ({
|
const issueMagicLinkInvite = async ({
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
@@ -1738,6 +2049,28 @@ app.post('/api/lost-bird/report', lostBirdReportLimiter, async (req: Request, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/public/birds/:publicProfileCode', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = publicProfileCodeSchema.safeParse(req.params.publicProfileCode);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(404).json({ error: 'Public bird profile not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bird = await getBirdByPublicProfileCode(parsed.data);
|
||||||
|
|
||||||
|
if (!bird) {
|
||||||
|
res.status(404).json({ error: 'Public bird profile not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ bird: normalizePublicBirdProfile(bird) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
app.get('/api/auth/providers', (_req: Request, res: Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
providers: Object.values(oauthProviders).map((provider) => ({
|
providers: Object.values(oauthProviders).map((provider) => ({
|
||||||
@@ -1811,7 +2144,7 @@ app.get('/api/auth/magic-link/verify', async (req: Request, res: Response, next:
|
|||||||
}
|
}
|
||||||
|
|
||||||
await claimWorkspaceInvites(user!);
|
await claimWorkspaceInvites(user!);
|
||||||
const receivingWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
const receivingWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||||
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
const transferCompletion = await completePendingBirdTransfersForOwner(user!.email, receivingWorkspaceId);
|
||||||
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
const memberships = await normalizeWorkspaceMembershipList(user!.id);
|
||||||
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
const activeWorkspaceId = transferCompletion.completed > 0 ? receivingWorkspaceId : memberships[0]?.workspace.id ?? receivingWorkspaceId;
|
||||||
@@ -2019,7 +2352,7 @@ const handleOAuthCallback = async (req: Request, res: Response, next: NextFuncti
|
|||||||
|
|
||||||
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
await linkAuthAccount(user!.id, providerKey, providerSubject, email);
|
||||||
await claimWorkspaceInvites(user!);
|
await claimWorkspaceInvites(user!);
|
||||||
const activeWorkspaceId = await ensurePersonalWorkspaceForUser(user!);
|
const activeWorkspaceId = await ensureDefaultWorkspaceForUser(user!);
|
||||||
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
await completePendingBirdTransfersForOwner(user!.email, activeWorkspaceId);
|
||||||
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
const { token } = await createAuthSession(user!.id, activeWorkspaceId);
|
||||||
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
const redirectUrl = new URL(oauthState.redirect_to || frontendBaseUrl);
|
||||||
@@ -2064,6 +2397,149 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const education = await listDailyEducationForAdmin();
|
||||||
|
res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const questions = await listEducationQuestionsForAdmin();
|
||||||
|
res.json({ questions: questions.map(normalizeEducationQuestion) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = dailyEducationSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const education = await upsertDailyEducation({
|
||||||
|
publishDate: parsed.data.publishDate,
|
||||||
|
fact: parsed.data.fact,
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
res.json({ education: normalizeDailyEducation(education) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationQuestionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await createEducationQuestion({
|
||||||
|
question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) },
|
||||||
|
createdByUserId: req.auth!.user.id,
|
||||||
|
});
|
||||||
|
res.status(201).json({ question: normalizeEducationQuestion(question) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationQuestionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await updateEducationQuestion(req.params.questionId, {
|
||||||
|
...parsed.data,
|
||||||
|
explanation: emptyToNull(parsed.data.explanation),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
res.status(404).json({ error: 'Education question not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ question: normalizeEducationQuestion(question) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const deleted = await deleteEducationQuestion(req.params.questionId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Education question not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const deleted = await deleteDailyEducation(req.params.educationId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ error: 'Daily education item not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const educationOptOut = await getEducationOptOut(req.auth!.user.id);
|
||||||
|
const education = educationOptOut ? null : await getDailyEducationForDate();
|
||||||
|
const questions = education ? await listDailyEducationQuestions(education.publish_date) : [];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
educationOptOut,
|
||||||
|
education: education ? normalizeDailyEducation(education, questions) : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const parsed = educationPreferenceSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut);
|
||||||
|
res.json({ educationOptOut });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
||||||
|
|
||||||
@@ -2302,15 +2778,6 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendRescueOnboardingWebhook({
|
|
||||||
action: 'created',
|
|
||||||
workspaceId,
|
|
||||||
flockName: parsed.data.name,
|
|
||||||
ownerEmail: req.auth!.user.email,
|
|
||||||
requestedByUserId: req.auth!.user.id,
|
|
||||||
rescueOnboarding: parsed.data.rescueOnboarding,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await createWorkspace({
|
const workspace = await createWorkspace({
|
||||||
@@ -2324,10 +2791,20 @@ app.post('/api/workspaces', requireAuth, requireSessionAuth, async (req: Request
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (workspace?.workspace_type === 'rescue') {
|
if (workspace?.workspace_type === 'rescue') {
|
||||||
|
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
|
||||||
|
action: 'created',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
flockName: workspace.name,
|
||||||
|
ownerEmail: req.auth!.user.email,
|
||||||
|
requestedByUserId: req.auth!.user.id,
|
||||||
|
rescueOnboarding: parsed.data.rescueOnboarding!,
|
||||||
|
});
|
||||||
|
|
||||||
await sendRescueStatusNotification({
|
await sendRescueStatusNotification({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail: req.auth!.user.email,
|
ownerEmail: req.auth!.user.email,
|
||||||
event: 'created',
|
event: 'created',
|
||||||
|
note: onboardingWebhookError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2375,15 +2852,6 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
res.status(400).json({ error: 'Rescue onboarding details are required.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendRescueOnboardingWebhook({
|
|
||||||
action: 'converted',
|
|
||||||
workspaceId: currentWorkspace.id,
|
|
||||||
flockName: parsed.data.name,
|
|
||||||
ownerEmail: req.auth!.user.email,
|
|
||||||
requestedByUserId: req.auth!.user.id,
|
|
||||||
rescueOnboarding: parsed.data.rescueOnboarding,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await updateWorkspace({
|
const workspace = await updateWorkspace({
|
||||||
@@ -2396,10 +2864,20 @@ app.put('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRole(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
|
if (workspace?.workspace_type === 'rescue' && isConvertingToRescue) {
|
||||||
|
const onboardingWebhookError = await trySendRescueOnboardingWebhook({
|
||||||
|
action: 'converted',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
flockName: workspace.name,
|
||||||
|
ownerEmail: req.auth!.user.email,
|
||||||
|
requestedByUserId: req.auth!.user.id,
|
||||||
|
rescueOnboarding: parsed.data.rescueOnboarding!,
|
||||||
|
});
|
||||||
|
|
||||||
await sendRescueStatusNotification({
|
await sendRescueStatusNotification({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail: req.auth!.user.email,
|
ownerEmail: req.auth!.user.email,
|
||||||
event: 'converted',
|
event: 'converted',
|
||||||
|
note: onboardingWebhookError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2416,13 +2894,15 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canceledStripeSubscriptionId = await cancelWorkspaceStripeSubscription(req.auth!.workspace);
|
||||||
|
|
||||||
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
let nextWorkspaceId = await findAlternateWorkspaceForUser(req.auth!.user.id, req.auth!.workspace.id);
|
||||||
|
|
||||||
if (!nextWorkspaceId) {
|
if (!nextWorkspaceId) {
|
||||||
const fallbackWorkspaceId = await getNextWorkspaceId();
|
const fallbackWorkspaceId = await getNextWorkspaceId();
|
||||||
const fallbackWorkspace = await createWorkspace({
|
const fallbackWorkspace = await createWorkspace({
|
||||||
id: fallbackWorkspaceId,
|
id: fallbackWorkspaceId,
|
||||||
name: `${req.auth!.user.name}'s Flock`,
|
name: 'New Flock',
|
||||||
workspaceType: 'standard',
|
workspaceType: 'standard',
|
||||||
billingEmail: req.auth!.user.email,
|
billingEmail: req.auth!.user.email,
|
||||||
billingPlan: 'household_basic',
|
billingPlan: 'household_basic',
|
||||||
@@ -2451,6 +2931,7 @@ app.delete('/api/workspace', requireAuth, requireSessionAuth, requireWorkspaceRo
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
deletedWorkspaceId: req.auth!.workspace.id,
|
deletedWorkspaceId: req.auth!.workspace.id,
|
||||||
|
canceledStripeSubscriptionId,
|
||||||
token: req.auth!.token,
|
token: req.auth!.token,
|
||||||
session: await buildSessionPayload(updatedAuth),
|
session: await buildSessionPayload(updatedAuth),
|
||||||
});
|
});
|
||||||
@@ -2547,6 +3028,66 @@ app.get('/api/birds', requireAuth, async (req: Request, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/birds/:birdId/photo', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const token = typeof req.query.token === 'string' ? req.query.token : '';
|
||||||
|
const photoAccess = verifyBirdPhotoAccessToken(token);
|
||||||
|
|
||||||
|
if (!photoAccess || photoAccess.birdId !== req.params.birdId) {
|
||||||
|
res.status(403).json({ error: 'Photo link expired or invalid.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bird = await getBirdById(photoAccess.birdId, photoAccess.workspaceId);
|
||||||
|
|
||||||
|
if (!bird || bird.photo_object_key !== photoAccess.objectKey) {
|
||||||
|
res.status(404).json({ error: 'Photo not found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
res.status(503).json({ error: 'Image storage is not configured.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedUrl = getSignedS3ObjectUrl({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey: bird.photo_object_key,
|
||||||
|
expiresInSeconds: 5 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=900');
|
||||||
|
|
||||||
|
if (photoDeliveryMode === 'redirect') {
|
||||||
|
res.redirect(302, signedUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageResponse = await fetch(signedUrl);
|
||||||
|
|
||||||
|
if (!imageResponse.ok) {
|
||||||
|
res.status(imageResponse.status).json({ error: 'Unable to load bird photo.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = imageResponse.headers.get('content-type') || bird.photo_content_type || 'application/octet-stream';
|
||||||
|
const contentLength = imageResponse.headers.get('content-length');
|
||||||
|
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
|
||||||
|
if (contentLength) {
|
||||||
|
res.setHeader('Content-Length', contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(imageBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const parsed = birdSchema.safeParse(req.body);
|
const parsed = birdSchema.safeParse(req.body);
|
||||||
|
|
||||||
@@ -2555,23 +3096,44 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uploadedObjectKeyToCleanup: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const birdId = crypto.randomUUID();
|
||||||
|
const photoStorage = await resolveBirdPhotoStorage({
|
||||||
|
birdId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||||
|
});
|
||||||
|
uploadedObjectKeyToCleanup = photoStorage.photoObjectKey;
|
||||||
const bird = await createBird({
|
const bird = await createBird({
|
||||||
|
birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: normalizeBandId(parsed.data.tagId),
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
species: parsed.data.species,
|
||||||
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
photoDataUrl: photoStorage.photoDataUrl,
|
||||||
|
photoObjectKey: photoStorage.photoObjectKey,
|
||||||
|
photoContentType: photoStorage.photoContentType,
|
||||||
|
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||||
|
publicProfileCode: createPublicProfileCode(),
|
||||||
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
uploadedObjectKeyToCleanup = null;
|
||||||
res.status(201).json({ bird: normalizeBird(bird!) });
|
res.status(201).json({ bird: normalizeBird(bird!) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||||
return;
|
return;
|
||||||
@@ -2664,6 +3226,8 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let uploadedObjectKeyToCleanup: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
const existingBird = await getBirdById(req.params.birdId, req.auth!.workspace.id);
|
||||||
|
|
||||||
@@ -2676,19 +3240,35 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const photoStorage = await resolveBirdPhotoStorage({
|
||||||
|
birdId: req.params.birdId,
|
||||||
|
workspaceId: req.auth!.workspace.id,
|
||||||
|
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
||||||
|
existingBird,
|
||||||
|
});
|
||||||
|
uploadedObjectKeyToCleanup =
|
||||||
|
photoStorage.photoObjectKey && photoStorage.photoObjectKey !== existingBird.photo_object_key ? photoStorage.photoObjectKey : null;
|
||||||
const bird = await updateBird({
|
const bird = await updateBird({
|
||||||
birdId: req.params.birdId,
|
birdId: req.params.birdId,
|
||||||
workspaceId: req.auth!.workspace.id,
|
workspaceId: req.auth!.workspace.id,
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
tagId: normalizeBandId(parsed.data.tagId),
|
tagId: normalizeBandId(parsed.data.tagId),
|
||||||
species: parsed.data.species,
|
species: parsed.data.species,
|
||||||
|
motivators: emptyToNull(parsed.data.motivators),
|
||||||
|
demotivators: emptyToNull(parsed.data.demotivators),
|
||||||
|
favoriteSnack: emptyToNull(parsed.data.favoriteSnack),
|
||||||
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
gender: (parsed.data.gender ?? 'unknown') as BirdGender,
|
||||||
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
dateOfBirth: emptyToNull(parsed.data.dateOfBirth),
|
||||||
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
gotchaDay: emptyToNull(parsed.data.gotchaDay),
|
||||||
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
chartColor: parsed.data.chartColor ?? '#cb3a35',
|
||||||
photoDataUrl: emptyToNull(parsed.data.photoDataUrl),
|
photoDataUrl: photoStorage.photoDataUrl,
|
||||||
|
photoObjectKey: photoStorage.photoObjectKey,
|
||||||
|
photoContentType: photoStorage.photoContentType,
|
||||||
|
photoUpdatedAt: photoStorage.photoUpdatedAt,
|
||||||
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
notifyOnDob: parsed.data.notifyOnDob ?? false,
|
||||||
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
notifyOnGotchaDay: parsed.data.notifyOnGotchaDay ?? false,
|
||||||
|
publicProfileCode: existingBird.public_profile_code ?? createPublicProfileCode(),
|
||||||
|
publicProfileEnabled: parsed.data.publicProfileEnabled ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bird) {
|
if (!bird) {
|
||||||
@@ -2696,8 +3276,12 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploadedObjectKeyToCleanup = null;
|
||||||
|
await deleteBirdPhotoObjectIfNeeded(photoStorage.objectKeyToDelete);
|
||||||
res.json({ bird: normalizeBird(bird) });
|
res.json({ bird: normalizeBird(bird) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await deleteBirdPhotoObjectIfNeeded(uploadedObjectKeyToCleanup);
|
||||||
|
|
||||||
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
||||||
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
res.status(409).json({ error: 'That band/tag ID is already in use in this flock.' });
|
||||||
return;
|
return;
|
||||||
@@ -2728,6 +3312,7 @@ app.delete('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspa
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
await deleteBirdPhotoObjectIfNeeded(bird.photo_object_key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
||||||
@@ -61,10 +64,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
WHERE workspace_type = 'rescue'
|
WHERE workspace_type = 'rescue'
|
||||||
AND rescue_verification_status = 'not_required';
|
AND rescue_verification_status = 'not_required';
|
||||||
|
|
||||||
INSERT INTO workspaces (id, name, workspace_type, billing_plan)
|
|
||||||
VALUES (1, 'My Flock', 'standard', 'household_basic')
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
@@ -143,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
ON auth_sessions (created_at DESC, user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_education (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
publish_date DATE NOT NULL UNIQUE,
|
||||||
|
fact TEXT NOT NULL,
|
||||||
|
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE daily_education
|
||||||
|
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
|
||||||
|
ON daily_education (publish_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS education_question_bank (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
prompt VARCHAR(500) NOT NULL,
|
||||||
|
options JSONB NOT NULL,
|
||||||
|
correct_answer_index INTEGER NOT NULL,
|
||||||
|
explanation VARCHAR(800),
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
|
||||||
|
ON education_question_bank (created_at DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS integration_tokens (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -216,13 +246,21 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
tag_id VARCHAR(80),
|
tag_id VARCHAR(80),
|
||||||
species VARCHAR(120) NOT NULL,
|
species VARCHAR(120) NOT NULL,
|
||||||
|
motivators VARCHAR(1000),
|
||||||
|
demotivators VARCHAR(1000),
|
||||||
|
favorite_snack VARCHAR(160),
|
||||||
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
gotcha_day DATE,
|
gotcha_day DATE,
|
||||||
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||||
photo_data_url TEXT,
|
photo_data_url TEXT,
|
||||||
|
photo_object_key TEXT,
|
||||||
|
photo_content_type VARCHAR(80),
|
||||||
|
photo_updated_at TIMESTAMPTZ,
|
||||||
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
public_profile_code VARCHAR(32),
|
||||||
|
public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
memorialized_at TIMESTAMPTZ,
|
memorialized_at TIMESTAMPTZ,
|
||||||
memorialized_on DATE,
|
memorialized_on DATE,
|
||||||
memorial_note VARCHAR(1000),
|
memorial_note VARCHAR(1000),
|
||||||
@@ -232,13 +270,21 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
|
|
||||||
ALTER TABLE birds
|
ALTER TABLE birds
|
||||||
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
|
ADD COLUMN IF NOT EXISTS workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000),
|
||||||
|
ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000),
|
||||||
|
ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160),
|
||||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown',
|
||||||
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
ADD COLUMN IF NOT EXISTS date_of_birth DATE,
|
||||||
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
ADD COLUMN IF NOT EXISTS gotcha_day DATE,
|
||||||
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
ADD COLUMN IF NOT EXISTS chart_color VARCHAR(7) NOT NULL DEFAULT '#cb3a35',
|
||||||
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
ADD COLUMN IF NOT EXISTS photo_data_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_object_key TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_content_type VARCHAR(80),
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_updated_at TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
ADD COLUMN IF NOT EXISTS notify_on_dob BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
ADD COLUMN IF NOT EXISTS notify_on_gotcha_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS public_profile_code VARCHAR(32),
|
||||||
|
ADD COLUMN IF NOT EXISTS public_profile_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
|
ADD COLUMN IF NOT EXISTS memorialized_at TIMESTAMPTZ,
|
||||||
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
ADD COLUMN IF NOT EXISTS memorialized_on DATE,
|
||||||
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
ADD COLUMN IF NOT EXISTS memorial_note VARCHAR(1000),
|
||||||
@@ -261,6 +307,12 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
DELETE FROM workspaces
|
||||||
|
WHERE id = 1
|
||||||
|
AND name = 'My Flock'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM workspace_members WHERE workspace_members.workspace_id = workspaces.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM birds WHERE birds.workspace_id = workspaces.id);
|
||||||
|
|
||||||
ALTER TABLE birds
|
ALTER TABLE birds
|
||||||
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
DROP CONSTRAINT IF EXISTS birds_tag_id_key;
|
||||||
|
|
||||||
@@ -287,6 +339,14 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
|
AND LOWER(BTRIM(tag_id)) NOT IN ('unknown', 'not recorded', 'n/a', 'na', 'none')
|
||||||
AND memorialized_at IS NULL;
|
AND memorialized_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_birds_photo_object_key
|
||||||
|
ON birds (photo_object_key)
|
||||||
|
WHERE photo_object_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_birds_public_profile_code
|
||||||
|
ON birds (public_profile_code)
|
||||||
|
WHERE public_profile_code IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
CREATE TABLE IF NOT EXISTS pending_bird_transfers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const enqueueBirdMilestoneReminderJob = (runDate: string): Promise<Job<Bi
|
|||||||
requestedBy: 'scheduler',
|
requestedBy: 'scheduler',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
jobId: `bird-milestone-reminders:${runDate}`,
|
jobId: `bird-milestone-reminders-${runDate}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
name: 'Kiwi',
|
name: 'Kiwi',
|
||||||
tag_id: 'A-1',
|
tag_id: 'A-1',
|
||||||
species: 'Cockatiel',
|
species: 'Cockatiel',
|
||||||
|
motivators: 'Step-up practice',
|
||||||
|
demotivators: 'Vacuum noise',
|
||||||
|
favorite_snack: 'Millet',
|
||||||
gender: 'female',
|
gender: 'female',
|
||||||
date_of_birth: null,
|
date_of_birth: null,
|
||||||
gotcha_day: null,
|
gotcha_day: null,
|
||||||
@@ -50,6 +53,9 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
name: 'Kiwi',
|
name: 'Kiwi',
|
||||||
tagId: 'A-1',
|
tagId: 'A-1',
|
||||||
species: 'Cockatiel',
|
species: 'Cockatiel',
|
||||||
|
motivators: 'Step-up practice',
|
||||||
|
demotivators: 'Vacuum noise',
|
||||||
|
favoriteSnack: 'Millet',
|
||||||
gender: 'female',
|
gender: 'female',
|
||||||
dateOfBirth: null,
|
dateOfBirth: null,
|
||||||
gotchaDay: null,
|
gotchaDay: null,
|
||||||
@@ -62,6 +68,7 @@ test('createBird returns the inserted bird row', async () => {
|
|||||||
assert.equal(bird?.name, 'Kiwi');
|
assert.equal(bird?.name, 'Kiwi');
|
||||||
assert.equal(bird?.workspace_id, 10);
|
assert.equal(bird?.workspace_id, 10);
|
||||||
assert.equal(bird?.gender, 'female');
|
assert.equal(bird?.gender, 'female');
|
||||||
|
assert.equal(bird?.favorite_snack, 'Millet');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
test('listWeightsForBird scopes by bird, workspace, and day window', async () => {
|
||||||
|
|||||||
@@ -20,13 +20,21 @@ const birdSelectFields = `
|
|||||||
birds.name,
|
birds.name,
|
||||||
birds.tag_id,
|
birds.tag_id,
|
||||||
birds.species,
|
birds.species,
|
||||||
|
birds.motivators,
|
||||||
|
birds.demotivators,
|
||||||
|
birds.favorite_snack,
|
||||||
birds.gender,
|
birds.gender,
|
||||||
birds.date_of_birth::text,
|
birds.date_of_birth::text,
|
||||||
birds.gotcha_day::text,
|
birds.gotcha_day::text,
|
||||||
birds.chart_color,
|
birds.chart_color,
|
||||||
birds.photo_data_url,
|
birds.photo_data_url,
|
||||||
|
birds.photo_object_key,
|
||||||
|
birds.photo_content_type,
|
||||||
|
birds.photo_updated_at,
|
||||||
birds.notify_on_dob,
|
birds.notify_on_dob,
|
||||||
birds.notify_on_gotcha_day,
|
birds.notify_on_gotcha_day,
|
||||||
|
birds.public_profile_code,
|
||||||
|
birds.public_profile_enabled,
|
||||||
birds.memorialized_at,
|
birds.memorialized_at,
|
||||||
birds.memorialized_on::text,
|
birds.memorialized_on::text,
|
||||||
birds.memorial_note,
|
birds.memorial_note,
|
||||||
@@ -56,6 +64,27 @@ export const getBirdById = async (birdId: string, workspaceId: number) => {
|
|||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBirdByPublicProfileCode = async (publicProfileCode: string) => {
|
||||||
|
const result = await db.query<BirdRow>(
|
||||||
|
`SELECT
|
||||||
|
${birdSelectFields}
|
||||||
|
FROM birds
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE birds.public_profile_code = $1
|
||||||
|
AND birds.public_profile_enabled = TRUE
|
||||||
|
AND birds.memorialized_at IS NULL`,
|
||||||
|
[publicProfileCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const listBirds = async (workspaceId: number) => {
|
export const listBirds = async (workspaceId: number) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -250,35 +279,74 @@ export const createBirdMilestoneReminderDelivery = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
|
birdId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
chartColor,
|
chartColor,
|
||||||
photoDataUrl,
|
photoDataUrl,
|
||||||
|
photoObjectKey = null,
|
||||||
|
photoContentType = null,
|
||||||
|
photoUpdatedAt = null,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode = null,
|
||||||
|
publicProfileEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
|
birdId?: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string | null;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favoriteSnack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
chartColor: string;
|
chartColor: string;
|
||||||
photoDataUrl: string | null;
|
photoDataUrl: string | null;
|
||||||
|
photoObjectKey?: string | null;
|
||||||
|
photoContentType?: string | null;
|
||||||
|
photoUpdatedAt?: string | null;
|
||||||
notifyOnDob: boolean;
|
notifyOnDob: boolean;
|
||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
|
publicProfileCode?: string | null;
|
||||||
|
publicProfileEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`INSERT INTO birds (workspace_id, name, tag_id, species, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day)
|
`INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`,
|
||||||
[workspaceId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay],
|
[
|
||||||
|
birdId ?? null,
|
||||||
|
workspaceId,
|
||||||
|
name,
|
||||||
|
tagId,
|
||||||
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
|
gender,
|
||||||
|
dateOfBirth,
|
||||||
|
gotchaDay,
|
||||||
|
chartColor,
|
||||||
|
photoDataUrl,
|
||||||
|
photoObjectKey,
|
||||||
|
photoContentType,
|
||||||
|
photoUpdatedAt,
|
||||||
|
notifyOnDob,
|
||||||
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -290,43 +358,67 @@ export const updateBird = async ({
|
|||||||
name,
|
name,
|
||||||
tagId,
|
tagId,
|
||||||
species,
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
gender,
|
gender,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
gotchaDay,
|
gotchaDay,
|
||||||
chartColor,
|
chartColor,
|
||||||
photoDataUrl,
|
photoDataUrl,
|
||||||
|
photoObjectKey = null,
|
||||||
|
photoContentType = null,
|
||||||
|
photoUpdatedAt = null,
|
||||||
notifyOnDob,
|
notifyOnDob,
|
||||||
notifyOnGotchaDay,
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
}: {
|
}: {
|
||||||
birdId: string;
|
birdId: string;
|
||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
name: string;
|
name: string;
|
||||||
tagId: string | null;
|
tagId: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favoriteSnack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
dateOfBirth: string | null;
|
dateOfBirth: string | null;
|
||||||
gotchaDay: string | null;
|
gotchaDay: string | null;
|
||||||
chartColor: string;
|
chartColor: string;
|
||||||
photoDataUrl: string | null;
|
photoDataUrl: string | null;
|
||||||
|
photoObjectKey?: string | null;
|
||||||
|
photoContentType?: string | null;
|
||||||
|
photoUpdatedAt?: string | null;
|
||||||
notifyOnDob: boolean;
|
notifyOnDob: boolean;
|
||||||
notifyOnGotchaDay: boolean;
|
notifyOnGotchaDay: boolean;
|
||||||
|
publicProfileCode: string | null;
|
||||||
|
publicProfileEnabled: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const result = await db.query<BirdRow>(
|
const result = await db.query<BirdRow>(
|
||||||
`UPDATE birds
|
`UPDATE birds
|
||||||
SET name = $2,
|
SET name = $2,
|
||||||
tag_id = $3,
|
tag_id = $3,
|
||||||
species = $4,
|
species = $4,
|
||||||
gender = $5,
|
motivators = $5,
|
||||||
date_of_birth = $6,
|
demotivators = $6,
|
||||||
gotcha_day = $7,
|
favorite_snack = $7,
|
||||||
chart_color = $8,
|
gender = $8,
|
||||||
photo_data_url = $9,
|
date_of_birth = $9,
|
||||||
notify_on_dob = $10,
|
gotcha_day = $10,
|
||||||
notify_on_gotcha_day = $11
|
chart_color = $11,
|
||||||
|
photo_data_url = $12,
|
||||||
|
photo_object_key = $13,
|
||||||
|
photo_content_type = $14,
|
||||||
|
photo_updated_at = $15,
|
||||||
|
notify_on_dob = $16,
|
||||||
|
notify_on_gotcha_day = $17,
|
||||||
|
public_profile_code = $18,
|
||||||
|
public_profile_enabled = $19
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $12
|
AND workspace_id = $20
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -341,7 +433,28 @@ export const updateBird = async ({
|
|||||||
ORDER BY recorded_on DESC
|
ORDER BY recorded_on DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS latest_recorded_on`,
|
) AS latest_recorded_on`,
|
||||||
[birdId, name, tagId, species, gender, dateOfBirth, gotchaDay, chartColor, photoDataUrl, notifyOnDob, notifyOnGotchaDay, workspaceId],
|
[
|
||||||
|
birdId,
|
||||||
|
name,
|
||||||
|
tagId,
|
||||||
|
species,
|
||||||
|
motivators,
|
||||||
|
demotivators,
|
||||||
|
favoriteSnack,
|
||||||
|
gender,
|
||||||
|
dateOfBirth,
|
||||||
|
gotchaDay,
|
||||||
|
chartColor,
|
||||||
|
photoDataUrl,
|
||||||
|
photoObjectKey,
|
||||||
|
photoContentType,
|
||||||
|
photoUpdatedAt,
|
||||||
|
notifyOnDob,
|
||||||
|
notifyOnGotchaDay,
|
||||||
|
publicProfileCode,
|
||||||
|
publicProfileEnabled,
|
||||||
|
workspaceId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
@@ -369,7 +482,7 @@ export const memorializeBird = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, 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, 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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -405,7 +518,7 @@ export const updateMemorialReminderPreference = async ({
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NOT NULL
|
AND memorialized_at IS NOT NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, 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, 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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
@@ -445,7 +558,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId:
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND memorialized_at IS NULL
|
AND memorialized_at IS NULL
|
||||||
RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, 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, 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,
|
||||||
(
|
(
|
||||||
SELECT weight_grams::text
|
SELECT weight_grams::text
|
||||||
FROM weight_records
|
FROM weight_records
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { db } from '../db/client.js';
|
||||||
|
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
|
||||||
|
|
||||||
|
export const getEducationOptOut = async (userId: string) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`SELECT education_opt_out
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
|
||||||
|
const result = await db.query<{ education_opt_out: boolean }>(
|
||||||
|
`UPDATE users
|
||||||
|
SET education_opt_out = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING education_opt_out`,
|
||||||
|
[userId, educationOptOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0]?.education_opt_out ?? educationOptOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDailyEducationForDate = async (publishDate?: string) => {
|
||||||
|
const result = publishDate
|
||||||
|
? await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = $1`,
|
||||||
|
[publishDate],
|
||||||
|
)
|
||||||
|
: await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
WHERE publish_date = CURRENT_DATE`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationForAdmin = async () => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
|
||||||
|
FROM daily_education
|
||||||
|
ORDER BY publish_date DESC
|
||||||
|
LIMIT 120`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDailyEducation = async ({
|
||||||
|
publishDate,
|
||||||
|
fact,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
publishDate: string;
|
||||||
|
fact: string;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<DailyEducationRow>(
|
||||||
|
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (publish_date) DO UPDATE
|
||||||
|
SET fact = EXCLUDED.fact,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
|
||||||
|
[publishDate, fact, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listEducationQuestionsForAdmin = async () => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT 400`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listDailyEducationQuestions = async (seedDate?: string) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
|
||||||
|
FROM education_question_bank
|
||||||
|
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
|
||||||
|
LIMIT 4`,
|
||||||
|
[seedDate ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEducationQuestion = async ({
|
||||||
|
question,
|
||||||
|
createdByUserId,
|
||||||
|
}: {
|
||||||
|
question: DailyEducationQuestion;
|
||||||
|
createdByUserId: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
|
||||||
|
VALUES ($1, $2::jsonb, $3, $4, $5)
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
|
||||||
|
const result = await db.query<EducationQuestionRow>(
|
||||||
|
`UPDATE education_question_bank
|
||||||
|
SET prompt = $2,
|
||||||
|
options = $3::jsonb,
|
||||||
|
correct_answer_index = $4,
|
||||||
|
explanation = $5,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
|
||||||
|
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEducationQuestion = async (questionId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM education_question_bank
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[questionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDailyEducation = async (educationId: string) => {
|
||||||
|
const result = await db.query<{ id: string }>(
|
||||||
|
`DELETE FROM daily_education
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id`,
|
||||||
|
[educationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rowCount);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
findAlternateWorkspaceForUser,
|
findAlternateWorkspaceForUser,
|
||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
@@ -34,6 +35,83 @@ test('ensurePersonalWorkspaceForUser returns an existing workspace without creat
|
|||||||
assert.match(calls[0].text, /FROM workspace_members/);
|
assert.match(calls[0].text, /FROM workspace_members/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensurePersonalWorkspaceForUser creates a fresh workspace instead of claiming the legacy seed flock', async () => {
|
||||||
|
const { calls } = mockDb(
|
||||||
|
{
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ next_id: 43 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceId = await ensurePersonalWorkspaceForUser(user);
|
||||||
|
|
||||||
|
assert.equal(workspaceId, 43);
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
assert.match(calls[1].text, /SELECT COALESCE\(MAX\(id\), 0\) \+ 1 AS next_id FROM workspaces/);
|
||||||
|
assert.match(calls[2].text, /INSERT INTO workspaces/);
|
||||||
|
assert.match(calls[3].text, /INSERT INTO workspace_members/);
|
||||||
|
assert.deepEqual(calls[2].params, [43, "Owner's Flock", 'owner@example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureDefaultWorkspaceForUser reuses an existing rescue workspace without creating a household flock', async () => {
|
||||||
|
const { calls } = mockDb({
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ workspace_id: 84 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
||||||
|
|
||||||
|
assert.equal(workspaceId, 84);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.match(calls[0].text, /FROM workspace_members/);
|
||||||
|
assert.doesNotMatch(calls[0].text, /workspaces\.workspace_type = 'standard'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureDefaultWorkspaceForUser creates a household flock when the user has no workspace', async () => {
|
||||||
|
const { calls } = mockDb(
|
||||||
|
{
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 0,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ next_id: 43 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceId = await ensureDefaultWorkspaceForUser(user);
|
||||||
|
|
||||||
|
assert.equal(workspaceId, 43);
|
||||||
|
assert.equal(calls.length, 5);
|
||||||
|
assert.match(calls[0].text, /FROM workspace_members/);
|
||||||
|
assert.match(calls[1].text, /workspaces\.workspace_type = 'standard'/);
|
||||||
|
assert.match(calls[3].text, /INSERT INTO workspaces/);
|
||||||
|
});
|
||||||
|
|
||||||
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
test('createWorkspace inserts owner membership and returns the created workspace', async () => {
|
||||||
const { calls } = mockDb(
|
const { calls } = mockDb(
|
||||||
{ rowCount: 1, rows: [] },
|
{ rowCount: 1, rows: [] },
|
||||||
|
|||||||
@@ -91,39 +91,13 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
return Number(existing.rows[0].workspace_id);
|
return Number(existing.rows[0].workspace_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unclaimed = await db.query<{ workspace_id: number }>(
|
const workspaceId = await getNextWorkspaceId();
|
||||||
`SELECT workspaces.id AS workspace_id
|
|
||||||
FROM workspaces
|
|
||||||
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
|
||||||
WHERE workspaces.id = 1
|
|
||||||
GROUP BY workspaces.id
|
|
||||||
HAVING COUNT(workspace_members.id) = 0
|
|
||||||
LIMIT 1`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspaceId = unclaimed.rowCount ? Number(unclaimed.rows[0].workspace_id) : await getNextWorkspaceId();
|
|
||||||
|
|
||||||
if (!unclaimed.rowCount) {
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
|
`INSERT INTO workspaces (id, name, workspace_type, billing_plan, billing_interval, billing_email, subscription_status, rescue_verification_status)
|
||||||
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
|
VALUES ($1, $2, 'standard', 'household_basic', 'monthly', $3, 'none', 'not_required')`,
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
[workspaceId, `${user.name}'s Flock`, user.email],
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await db.query(
|
|
||||||
`UPDATE workspaces
|
|
||||||
SET name = $2,
|
|
||||||
workspace_type = 'standard',
|
|
||||||
billing_plan = 'household_basic',
|
|
||||||
billing_interval = 'monthly',
|
|
||||||
billing_email = $3,
|
|
||||||
subscription_status = 'none',
|
|
||||||
rescue_verification_status = 'not_required',
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1`,
|
|
||||||
[workspaceId, `${user.name}'s Flock`, user.email],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
`INSERT INTO workspace_members (workspace_id, user_id, email, invite_email, name, role, accepted_at)
|
||||||
@@ -140,6 +114,24 @@ export const ensurePersonalWorkspaceForUser = async (user: UserRow) => {
|
|||||||
return workspaceId;
|
return workspaceId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ensureDefaultWorkspaceForUser = async (user: UserRow) => {
|
||||||
|
const existing = await db.query<{ workspace_id: number }>(
|
||||||
|
`SELECT workspace_id
|
||||||
|
FROM workspace_members
|
||||||
|
INNER JOIN workspaces ON workspaces.id = workspace_members.workspace_id
|
||||||
|
WHERE workspace_members.user_id = $1
|
||||||
|
ORDER BY workspaces.created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[user.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount) {
|
||||||
|
return Number(existing.rows[0].workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensurePersonalWorkspaceForUser(user);
|
||||||
|
};
|
||||||
|
|
||||||
export const claimWorkspaceInvites = async (user: UserRow) => {
|
export const claimWorkspaceInvites = async (user: UserRow) => {
|
||||||
await db.query(
|
await db.query(
|
||||||
`UPDATE workspace_members
|
`UPDATE workspace_members
|
||||||
@@ -388,7 +380,6 @@ export const deleteWorkspaceMember = async (memberId: string, workspaceId: numbe
|
|||||||
export const listRescueWorkspacesForAdmin = async () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
owner_email: string | null;
|
|
||||||
bird_count: number;
|
bird_count: number;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
}
|
}
|
||||||
@@ -406,17 +397,13 @@ export const listRescueWorkspacesForAdmin = async () => {
|
|||||||
workspaces.rescue_verification_status,
|
workspaces.rescue_verification_status,
|
||||||
workspaces.created_at,
|
workspaces.created_at,
|
||||||
workspaces.updated_at,
|
workspaces.updated_at,
|
||||||
owner.invite_email AS owner_email,
|
|
||||||
COUNT(DISTINCT birds.id)::int AS bird_count,
|
COUNT(DISTINCT birds.id)::int AS bird_count,
|
||||||
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
COUNT(DISTINCT workspace_members.id)::int AS member_count
|
||||||
FROM workspaces
|
FROM workspaces
|
||||||
LEFT JOIN workspace_members owner
|
|
||||||
ON owner.workspace_id = workspaces.id
|
|
||||||
AND owner.role = 'owner'
|
|
||||||
LEFT JOIN birds ON birds.workspace_id = workspaces.id
|
LEFT JOIN birds ON birds.workspace_id = workspaces.id
|
||||||
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
LEFT JOIN workspace_members ON workspace_members.workspace_id = workspaces.id
|
||||||
WHERE workspaces.workspace_type = 'rescue'
|
WHERE workspaces.workspace_type = 'rescue'
|
||||||
GROUP BY workspaces.id, owner.invite_email
|
GROUP BY workspaces.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE workspaces.rescue_verification_status
|
CASE workspaces.rescue_verification_status
|
||||||
WHEN 'pending' THEN 0
|
WHEN 'pending' THEN 0
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { ensureSchema } from '../db/schema.js';
|
||||||
|
import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
|
||||||
|
import { deleteS3Object, putS3Object } from '../storage/s3Client.js';
|
||||||
|
|
||||||
|
type BirdPhotoMigrationRow = {
|
||||||
|
id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
name: string;
|
||||||
|
photo_data_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseDataImage = (dataUrl: string) => {
|
||||||
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: match[1],
|
||||||
|
content: Buffer.from(match[2], 'base64'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArgValue = (name: string) => {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const match = process.argv.find((arg) => arg.startsWith(prefix));
|
||||||
|
return match ? match.slice(prefix.length) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dryRun = !process.argv.includes('--apply');
|
||||||
|
const keepDataUrl = process.argv.includes('--keep-data-url');
|
||||||
|
const limitArg = getArgValue('--limit');
|
||||||
|
const limit = limitArg ? Number(limitArg) : null;
|
||||||
|
|
||||||
|
if (limit !== null && (!Number.isInteger(limit) || limit <= 0)) {
|
||||||
|
console.error('Invalid --limit value. Use a positive integer.');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
await ensureSchema();
|
||||||
|
|
||||||
|
const s3Config = getS3ImageStorageConfig();
|
||||||
|
|
||||||
|
if (!s3Config) {
|
||||||
|
throw new Error('S3 image storage is not fully configured. Set IMAGE_STORAGE_PROVIDER=s3 and the S3_* environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query<BirdPhotoMigrationRow>(
|
||||||
|
`SELECT id, workspace_id, name, photo_data_url
|
||||||
|
FROM birds
|
||||||
|
WHERE photo_object_key IS NULL
|
||||||
|
AND photo_data_url LIKE 'data:image/%'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
${limit ? 'LIMIT $1' : ''}`,
|
||||||
|
limit ? [limit] : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`Dry run: ${result.rows.length} bird photo(s) would be migrated to bucket ${s3Config.bucket}.`);
|
||||||
|
console.log('Run with --apply to upload objects and update rows.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const bird of result.rows) {
|
||||||
|
const parsedImage = parseDataImage(bird.photo_data_url);
|
||||||
|
|
||||||
|
if (!parsedImage) {
|
||||||
|
skipped += 1;
|
||||||
|
console.warn(`Skipping bird ${bird.id} (${bird.name}): invalid data URL.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectKey = buildBirdPhotoObjectKey({
|
||||||
|
workspaceId: bird.workspace_id,
|
||||||
|
birdId: bird.id,
|
||||||
|
extension: getImageExtensionFromContentType(parsedImage.contentType),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putS3Object({
|
||||||
|
config: s3Config,
|
||||||
|
objectKey,
|
||||||
|
content: parsedImage.content,
|
||||||
|
contentType: parsedImage.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateResult = await db.query(
|
||||||
|
`UPDATE birds
|
||||||
|
SET photo_object_key = $2,
|
||||||
|
photo_content_type = $3,
|
||||||
|
photo_updated_at = CURRENT_TIMESTAMP,
|
||||||
|
photo_data_url = CASE WHEN $4::boolean THEN photo_data_url ELSE NULL END
|
||||||
|
WHERE id = $1
|
||||||
|
AND photo_object_key IS NULL
|
||||||
|
AND photo_data_url LIKE 'data:image/%'`,
|
||||||
|
[bird.id, objectKey, parsedImage.contentType, keepDataUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.rowCount !== 1) {
|
||||||
|
await deleteS3Object({ config: s3Config, objectKey });
|
||||||
|
skipped += 1;
|
||||||
|
console.warn(`Skipped bird ${bird.id} (${bird.name}): row changed before update.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated += 1;
|
||||||
|
console.log(`Migrated bird ${bird.id} (${bird.name}) -> ${objectKey}`);
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`Failed to migrate bird ${bird.id} (${bird.name}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete: migrated=${migrated}, skipped=${skipped}, failed=${failed}`);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Bird photo migration failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export type ImageStorageProvider = 'database' | 's3';
|
||||||
|
|
||||||
|
export type S3ImageStorageConfig = {
|
||||||
|
provider: 's3';
|
||||||
|
endpoint: string;
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
publicBaseUrl: string | null;
|
||||||
|
keyPrefix: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimOptional = (value: string | undefined) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageStorageProvider = (): ImageStorageProvider =>
|
||||||
|
process.env.IMAGE_STORAGE_PROVIDER === 's3' ? 's3' : 'database';
|
||||||
|
|
||||||
|
export const getS3ImageStorageConfig = (): S3ImageStorageConfig | null => {
|
||||||
|
if (getImageStorageProvider() !== 's3') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = trimOptional(process.env.S3_ENDPOINT);
|
||||||
|
const region = trimOptional(process.env.S3_REGION);
|
||||||
|
const bucket = trimOptional(process.env.S3_BUCKET);
|
||||||
|
const accessKeyId = trimOptional(process.env.S3_ACCESS_KEY_ID);
|
||||||
|
const secretAccessKey = trimOptional(process.env.S3_SECRET_ACCESS_KEY);
|
||||||
|
|
||||||
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 's3',
|
||||||
|
endpoint,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
publicBaseUrl: trimOptional(process.env.S3_PUBLIC_BASE_URL),
|
||||||
|
keyPrefix: trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isS3ImageStorageConfigured = () => getS3ImageStorageConfig() !== null;
|
||||||
|
|
||||||
|
export const buildBirdPhotoObjectKey = ({
|
||||||
|
workspaceId,
|
||||||
|
birdId,
|
||||||
|
extension,
|
||||||
|
now = new Date(),
|
||||||
|
}: {
|
||||||
|
workspaceId: number;
|
||||||
|
birdId: string;
|
||||||
|
extension: string;
|
||||||
|
now?: Date;
|
||||||
|
}) => {
|
||||||
|
const prefix = trimOptional(process.env.S3_KEY_PREFIX) ?? 'bird-photos';
|
||||||
|
const safeExtension = extension.replace(/^\./, '').toLowerCase() || 'bin';
|
||||||
|
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
||||||
|
|
||||||
|
return `${prefix}/workspace-${workspaceId}/${birdId}/${timestamp}.${safeExtension}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageExtensionFromContentType = (contentType: string) => {
|
||||||
|
switch (contentType.toLowerCase()) {
|
||||||
|
case 'image/jpeg':
|
||||||
|
case 'image/jpg':
|
||||||
|
return 'jpg';
|
||||||
|
case 'image/png':
|
||||||
|
return 'png';
|
||||||
|
case 'image/webp':
|
||||||
|
return 'webp';
|
||||||
|
case 'image/gif':
|
||||||
|
return 'gif';
|
||||||
|
default:
|
||||||
|
return 'bin';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import type { S3ImageStorageConfig } from './imageStorageConfig.js';
|
||||||
|
|
||||||
|
const awsDate = (date: Date) => date.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||||
|
const shortDate = (date: Date) => date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
|
||||||
|
const hmac = (key: crypto.BinaryLike, value: string) => crypto.createHmac('sha256', key).update(value).digest();
|
||||||
|
const sha256Hex = (value: crypto.BinaryLike) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
|
||||||
|
const encodeObjectKey = (key: string) => key.split('/').map(encodeURIComponent).join('/');
|
||||||
|
const encodeQueryValue = (value: string) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||||
|
|
||||||
|
const getSigningKey = (secretAccessKey: string, date: string, region: string) => {
|
||||||
|
const dateKey = hmac(`AWS4${secretAccessKey}`, date);
|
||||||
|
const regionKey = hmac(dateKey, region);
|
||||||
|
const serviceKey = hmac(regionKey, 's3');
|
||||||
|
return hmac(serviceKey, 'aws4_request');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
|
||||||
|
const endpoint = config.endpoint.replace(/\/+$/, '');
|
||||||
|
return new URL(`${endpoint}/${encodeURIComponent(config.bucket)}/${encodeObjectKey(objectKey)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const signS3Request = ({
|
||||||
|
config,
|
||||||
|
method,
|
||||||
|
objectKey,
|
||||||
|
contentHash,
|
||||||
|
contentType,
|
||||||
|
now = new Date(),
|
||||||
|
}: {
|
||||||
|
config: S3ImageStorageConfig;
|
||||||
|
method: 'DELETE' | 'PUT';
|
||||||
|
objectKey: string;
|
||||||
|
contentHash: string;
|
||||||
|
contentType?: string;
|
||||||
|
now?: Date;
|
||||||
|
}) => {
|
||||||
|
const url = buildObjectUrl(config, objectKey);
|
||||||
|
const amzDate = awsDate(now);
|
||||||
|
const date = shortDate(now);
|
||||||
|
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
host: url.host,
|
||||||
|
'x-amz-content-sha256': contentHash,
|
||||||
|
'x-amz-date': amzDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentType) {
|
||||||
|
headers['content-type'] = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedHeaders = Object.keys(headers).sort().join(';');
|
||||||
|
const canonicalHeaders = Object.keys(headers)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${key}:${headers[key]}\n`)
|
||||||
|
.join('');
|
||||||
|
const canonicalRequest = [method, url.pathname, url.search.slice(1), canonicalHeaders, signedHeaders, contentHash].join('\n');
|
||||||
|
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||||
|
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPublicObjectUrl = (config: S3ImageStorageConfig, objectKey: string) => {
|
||||||
|
if (config.publicBaseUrl) {
|
||||||
|
return `${config.publicBaseUrl.replace(/\/+$/, '')}/${encodeObjectKey(objectKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildObjectUrl(config, objectKey).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSignedS3ObjectUrl = ({
|
||||||
|
config,
|
||||||
|
objectKey,
|
||||||
|
expiresInSeconds = 900,
|
||||||
|
now = new Date(),
|
||||||
|
}: {
|
||||||
|
config: S3ImageStorageConfig;
|
||||||
|
objectKey: string;
|
||||||
|
expiresInSeconds?: number;
|
||||||
|
now?: Date;
|
||||||
|
}) => {
|
||||||
|
const url = buildObjectUrl(config, objectKey);
|
||||||
|
const amzDate = awsDate(now);
|
||||||
|
const date = shortDate(now);
|
||||||
|
const credentialScope = `${date}/${config.region}/s3/aws4_request`;
|
||||||
|
const credential = `${config.accessKeyId}/${credentialScope}`;
|
||||||
|
const queryParams: Record<string, string> = {
|
||||||
|
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||||
|
'X-Amz-Credential': credential,
|
||||||
|
'X-Amz-Date': amzDate,
|
||||||
|
'X-Amz-Expires': String(Math.min(Math.max(expiresInSeconds, 1), 604800)),
|
||||||
|
'X-Amz-SignedHeaders': 'host',
|
||||||
|
};
|
||||||
|
const canonicalQuery = Object.keys(queryParams)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${encodeQueryValue(key)}=${encodeQueryValue(queryParams[key])}`)
|
||||||
|
.join('&');
|
||||||
|
const canonicalHeaders = `host:${url.host}\n`;
|
||||||
|
const canonicalRequest = ['GET', url.pathname, canonicalQuery, canonicalHeaders, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
|
||||||
|
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||||
|
const signature = crypto.createHmac('sha256', getSigningKey(config.secretAccessKey, date, config.region)).update(stringToSign).digest('hex');
|
||||||
|
|
||||||
|
url.search = `${canonicalQuery}&X-Amz-Signature=${signature}`;
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const putS3Object = async ({
|
||||||
|
config,
|
||||||
|
objectKey,
|
||||||
|
content,
|
||||||
|
contentType,
|
||||||
|
}: {
|
||||||
|
config: S3ImageStorageConfig;
|
||||||
|
objectKey: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
}) => {
|
||||||
|
const contentHash = sha256Hex(content);
|
||||||
|
const signed = signS3Request({ config, method: 'PUT', objectKey, contentHash, contentType });
|
||||||
|
const response = await fetch(signed.url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: signed.headers,
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Wasabi upload failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPublicObjectUrl(config, objectKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteS3Object = async ({
|
||||||
|
config,
|
||||||
|
objectKey,
|
||||||
|
}: {
|
||||||
|
config: S3ImageStorageConfig;
|
||||||
|
objectKey: string;
|
||||||
|
}) => {
|
||||||
|
const signed = signS3Request({ config, method: 'DELETE', objectKey, contentHash: sha256Hex('') });
|
||||||
|
const response = await fetch(signed.url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: signed.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Wasabi delete failed with ${response.status}${errorText ? `: ${errorText.slice(0, 300)}` : ''}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,9 +13,38 @@ export type UserRow = {
|
|||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
education_opt_out?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DailyEducationQuestion = {
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correctAnswerIndex: number;
|
||||||
|
explanation: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DailyEducationRow = {
|
||||||
|
id: string;
|
||||||
|
publish_date: string;
|
||||||
|
fact: string;
|
||||||
|
quiz_questions: DailyEducationQuestion[];
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EducationQuestionRow = {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
options: string[];
|
||||||
|
correct_answer_index: number;
|
||||||
|
explanation: string | null;
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkspaceRow = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -98,13 +127,21 @@ export type BirdRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
tag_id: string | null;
|
tag_id: string | null;
|
||||||
species: string;
|
species: string;
|
||||||
|
motivators: string | null;
|
||||||
|
demotivators: string | null;
|
||||||
|
favorite_snack: string | null;
|
||||||
gender: BirdGender;
|
gender: BirdGender;
|
||||||
date_of_birth: string | null;
|
date_of_birth: string | null;
|
||||||
gotcha_day: string | null;
|
gotcha_day: string | null;
|
||||||
chart_color: string;
|
chart_color: string;
|
||||||
photo_data_url: string | null;
|
photo_data_url: string | null;
|
||||||
|
photo_object_key: string | null;
|
||||||
|
photo_content_type: string | null;
|
||||||
|
photo_updated_at: string | null;
|
||||||
notify_on_dob: boolean;
|
notify_on_dob: boolean;
|
||||||
notify_on_gotcha_day: boolean;
|
notify_on_gotcha_day: boolean;
|
||||||
|
public_profile_code: string | null;
|
||||||
|
public_profile_enabled: boolean;
|
||||||
memorialized_at: string | null;
|
memorialized_at: string | null;
|
||||||
memorialized_on: string | null;
|
memorialized_on: string | null;
|
||||||
memorial_note: string | null;
|
memorial_note: string | null;
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||||
|
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||||
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
@@ -111,6 +120,15 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD for production}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||||
|
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||||
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
FRONTEND_URL: ${FRONTEND_URL:?set FRONTEND_URL for production}
|
||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||||
|
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||||
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
@@ -104,6 +113,15 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
POSTGRES_USER: ${POSTGRES_USER:-flockpal}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-flockpal_dev_password}
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||||
|
IMAGE_STORAGE_PROVIDER: ${IMAGE_STORAGE_PROVIDER:-database}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||||
|
S3_REGION: ${S3_REGION:-}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-}
|
||||||
|
S3_KEY_PREFIX: ${S3_KEY_PREFIX:-bird-photos}
|
||||||
|
PHOTO_DELIVERY_MODE: ${PHOTO_DELIVERY_MODE:-proxy}
|
||||||
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
|
|||||||
Generated
+497
-1
@@ -8,8 +8,11 @@
|
|||||||
"name": "flockpal-frontend",
|
"name": "flockpal-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
@@ -1144,6 +1147,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
|
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1151,6 +1163,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.12",
|
"version": "18.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
|
||||||
@@ -1192,6 +1213,39 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.9.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
|
||||||
|
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.16",
|
"version": "2.10.16",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
|
||||||
@@ -1205,6 +1259,12 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||||
@@ -1239,6 +1299,15 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001786",
|
"version": "1.0.30001786",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||||
@@ -1260,6 +1329,35 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1267,6 +1365,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1292,6 +1396,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/duplexer2": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.331",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
||||||
@@ -1299,6 +1427,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1348,6 +1482,39 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||||
|
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "11.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||||
|
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1373,6 +1540,42 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1405,6 +1608,30 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1453,6 +1680,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-int64": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.37",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
||||||
@@ -1460,6 +1693,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1467,6 +1745,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -1496,6 +1783,29 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -1531,6 +1841,50 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/read-excel-file": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-FWwC3IypIQDVPTtO4pz0Sq6An7lQI17pXqCusaTX8yi3p9CCRtXx/SI3BtcPSTaLhwcwr9mI+KXSa/dWMmnvjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.9.9",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"unzipper": "^0.12.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -1576,6 +1930,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1595,6 +1955,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1605,6 +1971,41 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.6.3",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||||
@@ -1619,6 +2020,34 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipper": {
|
||||||
|
"version": "0.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||||
|
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bluebird": "~3.7.2",
|
||||||
|
"duplexer2": "~0.1.4",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"node-int64": "^0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@@ -1650,6 +2079,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.10",
|
"version": "5.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
|
||||||
@@ -1710,12 +2145,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"read-excel-file": "^9.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
|
|||||||
+2144
-276
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
+497
-9
@@ -122,6 +122,70 @@ textarea {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-alert-notification {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid rgba(203, 58, 53, 0.26);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 247, 244, 0.98), rgba(255, 238, 231, 0.96));
|
||||||
|
box-shadow: 0 16px 30px rgba(203, 58, 53, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-notification div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-notification strong {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-notification span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(203, 58, 53, 0.12);
|
||||||
|
border: 1px solid rgba(203, 58, 53, 0.22);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 7px;
|
||||||
|
width: 12px;
|
||||||
|
height: 15px;
|
||||||
|
border: 2px solid var(--accent-red);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 8px 8px 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 22px;
|
||||||
|
width: 10px;
|
||||||
|
height: 5px;
|
||||||
|
border-top: 2px solid var(--accent-red);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 2rem;
|
top: 2rem;
|
||||||
@@ -155,6 +219,53 @@ textarea {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.public-profile-shell {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-logo {
|
||||||
|
width: min(220px, 70%);
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 10px 18px rgba(86, 63, 34, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-logo-link {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-photo {
|
||||||
|
width: min(260px, 100%);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.16);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-profile-copy h1 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-hero-card {
|
.auth-hero-card {
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@@ -509,22 +620,26 @@ textarea {
|
|||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-collaborators {
|
.settings-card-bird-profiles[hidden] {
|
||||||
order: 2;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-separate-flock {
|
.settings-card-collaborators {
|
||||||
order: 3;
|
order: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-automation {
|
.settings-card-automation {
|
||||||
order: 4;
|
order: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-card-transfer {
|
.settings-card-bird-import {
|
||||||
order: 5;
|
order: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-card-transfer {
|
||||||
|
order: 6;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card-flock-profile {
|
.settings-card-flock-profile {
|
||||||
order: 1;
|
order: 1;
|
||||||
}
|
}
|
||||||
@@ -567,6 +682,21 @@ textarea {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-column-guide {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 254, 250, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-danger-card {
|
.settings-danger-card {
|
||||||
border-color: rgba(203, 58, 53, 0.22);
|
border-color: rgba(203, 58, 53, 0.22);
|
||||||
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
background: linear-gradient(180deg, rgba(255, 250, 246, 0.86), rgba(255, 241, 238, 0.72));
|
||||||
@@ -615,12 +745,141 @@ textarea {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daily-education-panel,
|
||||||
|
.daily-quiz,
|
||||||
|
.quiz-options,
|
||||||
|
.education-question-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-panel.condensed {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-panel.condensed .panel-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-education-teaser {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-fact {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid var(--accent-gold);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
background: rgba(255, 254, 250, 0.7);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-quiz {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question,
|
||||||
|
.quiz-editor-question {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--button-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 254, 250, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-question legend,
|
||||||
|
.quiz-editor-question legend {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option.correct {
|
||||||
|
border-color: rgba(35, 138, 90, 0.42);
|
||||||
|
background: rgba(223, 247, 229, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option.incorrect {
|
||||||
|
border-color: rgba(203, 58, 53, 0.36);
|
||||||
|
background: rgba(255, 236, 232, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-option input {
|
||||||
|
width: auto;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-feedback {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-feedback.correct {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-education-panel,
|
||||||
|
.education-admin-basics,
|
||||||
|
.quiz-editor-question,
|
||||||
|
.education-admin-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-admin-basics {
|
||||||
|
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-editor-question {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-editor-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.education-admin-list span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.button-row {
|
.button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-header-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
justify-content: end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-header-actions .bird-alert-stack {
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
.billing-inline-action {
|
.billing-inline-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -896,6 +1155,17 @@ textarea {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.latest-weight-callout rect {
|
||||||
|
fill: rgba(255, 253, 249, 0.94);
|
||||||
|
stroke: rgba(31, 42, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-weight-callout text {
|
||||||
|
fill: var(--text);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.historical-weight-line,
|
.historical-weight-line,
|
||||||
.historical-weight-dot {
|
.historical-weight-dot {
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
@@ -941,6 +1211,32 @@ textarea {
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.92), rgba(240, 248, 244, 0.84));
|
||||||
border: 1px solid rgba(39, 105, 179, 0.1);
|
border: 1px solid rgba(39, 105, 179, 0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-profile-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.85rem;
|
||||||
|
right: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid rgba(39, 105, 179, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 254, 250, 0.9);
|
||||||
|
box-shadow: 0 10px 20px rgba(86, 63, 34, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-profile-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(35, 138, 90, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-profile-button svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
fill: var(--accent-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
@@ -953,8 +1249,7 @@ textarea {
|
|||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-title,
|
.profile-title {
|
||||||
.detail-gender {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -1052,6 +1347,25 @@ textarea {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-inline-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-inline-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list-fields input {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.care-form-actions {
|
.care-form-actions {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -1120,6 +1434,21 @@ textarea {
|
|||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing-contact-email {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item-list li + li {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-list {
|
.summary-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -1364,6 +1693,21 @@ label {
|
|||||||
accent-color: var(--accent-green);
|
accent-color: var(--accent-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-field input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
.primary-button {
|
.primary-button {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -1548,12 +1892,85 @@ label {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qr-modal {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-print-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
height: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-bird-mark rect {
|
||||||
|
fill: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-print-card h3,
|
||||||
|
.qr-print-card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-print-card p {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-alert-list {
|
.modal-alert-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before,
|
||||||
|
.no-print {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-modal-backdrop {
|
||||||
|
position: static;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
background: #fff;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-modal {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-print-card {
|
||||||
|
min-height: 100vh;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.education-admin-basics,
|
||||||
|
.quiz-editor-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.auth-panel,
|
.auth-panel,
|
||||||
.hero-card,
|
.hero-card,
|
||||||
@@ -1570,6 +1987,7 @@ label {
|
|||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
@@ -1581,11 +1999,81 @@ label {
|
|||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav {
|
.top-alert-notification {
|
||||||
position: static;
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-alert-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: static;
|
position: static;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup {
|
||||||
|
justify-items: start;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav-logo {
|
||||||
|
width: min(120px, 27vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav.panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tabs {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tab {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-nav .secondary-button {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-item {
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-switcher-item small {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
if [ -z "${BASH_VERSION:-}" ]; then
|
||||||
|
exec bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
compose_file="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
if [ -z "${BASH_VERSION:-}" ]; then
|
||||||
|
exec bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
if [[ $# -ne 1 ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user