Compare commits

7 Commits

Author SHA1 Message Date
Replit Agent e2564e2a66 Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header; rendered disabled
  (not hidden) when the album has no photos.
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:37:15 +00:00
Replit Agent 56c8bcfb85 Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.

Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
  types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
  Content-Disposition: attachment (with optional ?name base + derived ext);
  default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
  photos as a ZIP (guarded by requireProtocolAccess), named from the album;
  unreadable objects are skipped rather than failing the archive. Empty album
  returns 404.
- Added archiver@8 dependency (+@types/archiver dev).

Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header (hidden when empty).
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.

Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
  `new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
  api-server typecheck + build pass, tx-os typecheck passes, server healthy,
  new routes wired (401 auth-required not 404). Pre-existing typecheck errors
  in executive-meetings.ts / push.ts are unrelated and untouched.
2026-07-07 09:34:06 +00:00
Replit Agent 3a2f70c831 Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: cc157e6f-25a1-4ce4-84d1-2949a36d40a4
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 09:25:28 +00:00
Replit Agent 3f3c9671f5 Add functionality to manage visit photos and albums for protocols
Introduce new database tables for photo albums and photos, add API endpoints for CRUD operations on albums and photos, and implement frontend components for managing photo albums.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ad3facca-9626-4e19-95c0-125e6d3bc7e2
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/1OXOhLE
Replit-Helium-Checkpoint-Created: true
2026-07-07 09:14:07 +00:00
Replit Agent 9dd8c32d6d Transitioned from Plan to Build mode
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 738505dd-e8e3-41e3-85df-c28000513ea0
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/bJodRQl
Replit-Helium-Checkpoint-Created: true
2026-07-07 08:58:54 +00:00
Replit Agent aaa2d0b48d Make external meeting titles display in red
Update the styling of the MeetingRow component to conditionally apply red text color to the meeting title when the `isExternal` property is true.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1f41191d-7d28-4e72-bd38-191f56bae01b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/bJodRQl
Replit-Helium-Checkpoint-Created: true
2026-07-07 08:24:00 +00:00
Replit Agent ea204894aa Update tab name for dedications in protocol app
Rename "Gifts & Shields" to "Dedications" in English and "الهدايا والدروع" to "الإهداءات" in Arabic within the protocol app's locale files.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 4fc9b8d2-3d7b-4f65-b980-61b4ffc1e22d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/bJodRQl
Replit-Helium-Checkpoint-Created: true
2026-07-07 08:06:53 +00:00
11 changed files with 1426 additions and 5 deletions
+2
View File
@@ -20,6 +20,7 @@
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"arabic-persian-reshaper": "1.0.1",
"archiver": "^8.0.0",
"bcryptjs": "^3.0.3",
"bidi-js": "^1.0.3",
"connect-pg-simple": "^10.0.0",
@@ -41,6 +42,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
+14 -1
View File
@@ -5,10 +5,11 @@ import {
servicesTable,
executiveMeetingFontSettingsTable,
executiveMeetingPdfArchivesTable,
protocolPhotosTable,
rolesTable,
} from "@workspace/db";
import { eq, inArray, sql } from "drizzle-orm";
import { getEffectiveRoleIds } from "../middlewares/auth";
import { getEffectiveRoleIds, userHasPermission } from "../middlewares/auth";
import { getVisibleAppsForUser } from "./appsVisibility";
// Roles that gate read access to the Executive Meetings module.
@@ -152,6 +153,18 @@ export async function canUserReadObjectPath(
const rows = (meeting as unknown as { rows?: unknown[] }).rows ?? [];
if (rows.length > 0) return hasExecutiveAccess;
// 7. Protocol visit photos — gated by the protocol read permission
// (`protocol.access`), the same scoped permission that makes the
// Protocol tile visible. Never gated by role name (module convention).
const photo = await db
.select({ id: protocolPhotosTable.id })
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.objectPath, objectPath))
.limit(1);
if (photo.length > 0) {
return isAdmin || (await userHasPermission(userId, "protocol.access"));
}
// Orphan path — no entity references it. Treat as not-found for
// every role (including admin). This is what stops object
// enumeration: even with admin credentials, you cannot probe
@@ -25,6 +25,30 @@ export class ObjectNotFoundError extends Error {
}
}
// Map a stored object's content type to a sensible file extension so
// downloads land with a usable name (stored paths are extension-less UUIDs).
const CONTENT_TYPE_EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"image/bmp": "bmp",
"image/tiff": "tiff",
"image/svg+xml": "svg",
"application/pdf": "pdf",
};
export function extensionForContentType(
contentType: string | undefined,
): string {
if (!contentType) return "bin";
const base = contentType.split(";")[0].trim().toLowerCase();
return CONTENT_TYPE_EXTENSIONS[base] ?? "bin";
}
// ---------------------------------------------------------------------------
// Driver interface
// ---------------------------------------------------------------------------
+382
View File
@@ -9,6 +9,8 @@ import {
protocolExternalMeetingsTable,
protocolGiftsTable,
protocolGiftIssuesTable,
protocolPhotoAlbumsTable,
protocolPhotosTable,
protocolAuditLogsTable,
rolesTable,
permissionsTable,
@@ -21,9 +23,16 @@ import {
getEffectiveRoleIds,
userHasPermission,
} from "../middlewares/auth";
import {
ObjectStorageService,
extensionForContentType,
} from "../lib/objectStorage";
import { ZipArchive } from "archiver";
import { Readable } from "stream";
import type { Request, Response, NextFunction } from "express";
const router: IRouter = Router();
const objectStorage = new ObjectStorageService();
router.param("id", (req, res, next, value) => {
if (!/^\d+$/.test(String(value))) {
@@ -1848,4 +1857,377 @@ router.get(
},
);
// ---------------------------------------------------------------------------
// Visit photo albums (تصوير) — album + photo CRUD.
//
// Reads are gated by requireProtocolAccess; every mutation by requireMutate
// (both also enforce the user is active). Photos reference private object-
// storage paths served through GET /api/storage/objects/*, authorized per
// entity in lib/objectAuthz.ts.
// ---------------------------------------------------------------------------
const albumCreateSchema = z.object({
name: z.string().trim().min(1).max(300),
externalMeetingId: z.number().int().positive().nullable().optional(),
});
const albumPatchSchema = z
.object({
name: z.string().trim().min(1).max(300).optional(),
externalMeetingId: z.number().int().positive().nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, {
message: "no fields to update",
});
const photoCreateSchema = z.object({
objectPath: z.string().trim().min(1).max(500),
});
async function externalMeetingExists(id: number): Promise<boolean> {
const [m] = await db
.select({ id: protocolExternalMeetingsTable.id })
.from(protocolExternalMeetingsTable)
.where(eq(protocolExternalMeetingsTable.id, id));
return !!m;
}
router.get(
"/protocol/photo-albums",
requireProtocolAccess,
async (req: Request, res: Response) => {
const meetingIdRaw = req.query.meetingId;
const meetingId =
typeof meetingIdRaw === "string" && /^\d+$/.test(meetingIdRaw)
? Number(meetingIdRaw)
: undefined;
const rows = await db
.select({
id: protocolPhotoAlbumsTable.id,
name: protocolPhotoAlbumsTable.name,
externalMeetingId: protocolPhotoAlbumsTable.externalMeetingId,
meetingTitle: protocolExternalMeetingsTable.title,
createdBy: protocolPhotoAlbumsTable.createdBy,
createdAt: protocolPhotoAlbumsTable.createdAt,
updatedAt: protocolPhotoAlbumsTable.updatedAt,
photoCount: sql<number>`count(${protocolPhotosTable.id})::int`,
coverPath: sql<
string | null
>`(array_agg(${protocolPhotosTable.objectPath} order by ${protocolPhotosTable.sortOrder} asc, ${protocolPhotosTable.id} asc) filter (where ${protocolPhotosTable.id} is not null))[1]`,
})
.from(protocolPhotoAlbumsTable)
.leftJoin(
protocolExternalMeetingsTable,
eq(
protocolExternalMeetingsTable.id,
protocolPhotoAlbumsTable.externalMeetingId,
),
)
.leftJoin(
protocolPhotosTable,
eq(protocolPhotosTable.albumId, protocolPhotoAlbumsTable.id),
)
.where(
meetingId !== undefined
? eq(protocolPhotoAlbumsTable.externalMeetingId, meetingId)
: undefined,
)
.groupBy(
protocolPhotoAlbumsTable.id,
protocolExternalMeetingsTable.title,
)
.orderBy(desc(protocolPhotoAlbumsTable.createdAt));
res.json(rows);
},
);
router.post(
"/protocol/photo-albums",
requireMutate,
async (req: Request, res: Response) => {
const body = parseBody(res, albumCreateSchema, req.body);
if (!body) return;
if (
body.externalMeetingId != null &&
!(await externalMeetingExists(body.externalMeetingId))
) {
res
.status(400)
.json({ error: "external_meeting not found", code: "validation" });
return;
}
const [row] = await db
.insert(protocolPhotoAlbumsTable)
.values({
name: body.name,
externalMeetingId: body.externalMeetingId ?? null,
createdBy: req.session.userId!,
})
.returning();
await logAudit(db, {
action: "photo_album.create",
entityType: "photo_album",
entityId: row.id,
newValue: row,
performedBy: req.session.userId!,
});
res.status(201).json(row);
},
);
router.patch(
"/protocol/photo-albums/:id",
requireMutate,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const body = parseBody(res, albumPatchSchema, req.body);
if (!body) return;
const [existing] = await db
.select()
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
if (
body.externalMeetingId != null &&
!(await externalMeetingExists(body.externalMeetingId))
) {
res
.status(400)
.json({ error: "external_meeting not found", code: "validation" });
return;
}
const [row] = await db
.update(protocolPhotoAlbumsTable)
.set({
...(body.name !== undefined ? { name: body.name } : {}),
...(body.externalMeetingId !== undefined
? { externalMeetingId: body.externalMeetingId }
: {}),
})
.where(eq(protocolPhotoAlbumsTable.id, id))
.returning();
await logAudit(db, {
action: "photo_album.update",
entityType: "photo_album",
entityId: id,
oldValue: existing,
newValue: row,
performedBy: req.session.userId!,
});
res.json(row);
},
);
router.delete(
"/protocol/photo-albums/:id",
requireMutate,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const [existing] = await db
.select()
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// Photos cascade-delete via the FK; audit the album deletion.
await db
.delete(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, id));
await logAudit(db, {
action: "photo_album.delete",
entityType: "photo_album",
entityId: id,
oldValue: existing,
performedBy: req.session.userId!,
});
res.status(204).end();
},
);
router.get(
"/protocol/photo-albums/:id/photos",
requireProtocolAccess,
async (req: Request, res: Response) => {
const albumId = Number(req.params.id);
const [album] = await db
.select({ id: protocolPhotoAlbumsTable.id })
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, albumId));
if (!album) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const rows = await db
.select()
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.albumId, albumId))
.orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id));
res.json(rows);
},
);
// Stream every photo of an album as a single ZIP. Guarded by protocol
// read access; each entry is named sequentially with an extension derived
// from the stored object's content type. Missing/unreadable objects are
// skipped rather than failing the whole archive.
router.get(
"/protocol/photo-albums/:id/download",
requireProtocolAccess,
async (req: Request, res: Response) => {
const albumId = Number(req.params.id);
const [album] = await db
.select({
id: protocolPhotoAlbumsTable.id,
name: protocolPhotoAlbumsTable.name,
})
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, albumId));
if (!album) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
const rows = await db
.select()
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.albumId, albumId))
.orderBy(asc(protocolPhotosTable.sortOrder), asc(protocolPhotosTable.id));
if (rows.length === 0) {
res.status(404).json({ error: "album is empty", code: "not_found" });
return;
}
const safeName =
album.name
.replace(/[\r\n"\\/]/g, "")
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 180) || "album";
const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_") || "album";
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodeURIComponent(
`${safeName}.zip`,
)}`,
);
const archive = new ZipArchive({ zlib: { level: 6 } });
archive.on("error", (err: Error) => {
req.log.error({ err }, "Error building album zip");
if (!res.headersSent) {
res.status(500).json({ error: "Failed to build archive" });
} else {
res.destroy();
}
});
archive.pipe(res);
let index = 0;
for (const photo of rows) {
index += 1;
try {
const file = await objectStorage.getObjectEntityFile(photo.objectPath);
const meta = await file.getMetadata();
const ext = extensionForContentType(meta.contentType);
const entryName = `${String(index).padStart(3, "0")}.${ext}`;
archive.append(file.createReadStream() as Readable, {
name: entryName,
});
} catch (err) {
req.log.warn(
{ err, photoId: photo.id },
"Skipping unreadable photo in album zip",
);
}
}
await archive.finalize();
},
);
router.post(
"/protocol/photo-albums/:id/photos",
requireMutate,
async (req: Request, res: Response) => {
const albumId = Number(req.params.id);
const body = parseBody(res, photoCreateSchema, req.body);
if (!body) return;
const [album] = await db
.select({ id: protocolPhotoAlbumsTable.id })
.from(protocolPhotoAlbumsTable)
.where(eq(protocolPhotoAlbumsTable.id, albumId));
if (!album) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
// Normalize any signed URL / legacy form back to the canonical
// /objects/<id> path and reject anything that is not a private object.
const objectPath = objectStorage.normalizeObjectEntityPath(body.objectPath);
if (!objectPath.startsWith("/objects/")) {
res.status(400).json({ error: "invalid objectPath", code: "validation" });
return;
}
// Verify the object was actually uploaded before storing a reference to
// it — this rejects dangling/guessed paths.
try {
await objectStorage.getObjectEntityFile(objectPath);
} catch {
res.status(400).json({ error: "object not found", code: "validation" });
return;
}
const [agg] = await db
.select({
maxOrder: sql<
number
>`coalesce(max(${protocolPhotosTable.sortOrder}), -1)::int`,
})
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.albumId, albumId));
const [row] = await db
.insert(protocolPhotosTable)
.values({
albumId,
objectPath,
sortOrder: (agg?.maxOrder ?? -1) + 1,
uploadedBy: req.session.userId!,
})
.returning();
await logAudit(db, {
action: "photo.create",
entityType: "photo",
entityId: row.id,
newValue: row,
performedBy: req.session.userId!,
});
res.status(201).json(row);
},
);
router.delete(
"/protocol/photos/:id",
requireMutate,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
const [existing] = await db
.select()
.from(protocolPhotosTable)
.where(eq(protocolPhotosTable.id, id));
if (!existing) {
res.status(404).json({ error: "Not found", code: "not_found" });
return;
}
await db.delete(protocolPhotosTable).where(eq(protocolPhotosTable.id, id));
await logAudit(db, {
action: "photo.delete",
entityType: "photo",
entityId: id,
oldValue: existing,
performedBy: req.session.userId!,
});
res.status(204).end();
},
);
export default router;
+33 -1
View File
@@ -4,7 +4,11 @@ import {
RequestUploadUrlBody,
RequestUploadUrlResponse,
} from "@workspace/api-zod";
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
import {
ObjectStorageService,
ObjectNotFoundError,
extensionForContentType,
} from "../lib/objectStorage";
import { canUserReadObjectPath } from "../lib/objectAuthz";
import { requireAuth } from "../middlewares/auth";
@@ -109,6 +113,34 @@ router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Resp
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
// Opt-in force-download: `?download=1` serves the object as an
// attachment (Content-Disposition) so browsers save it instead of
// rendering inline. An optional `name` base is combined with the
// extension derived from the object's content type.
if (req.query.download !== undefined) {
const contentType = response.headers.get("content-type") ?? undefined;
const ext = extensionForContentType(contentType);
const baseRaw =
typeof req.query.name === "string" && req.query.name.trim()
? req.query.name
: (wildcardPath.split("/").pop() ?? "download");
// Strip characters that would break the header or the filesystem.
const safeBase = baseRaw
.replace(/[\r\n"\\/]/g, "")
.replace(/[\u0000-\u001f]/g, "")
.trim()
.slice(0, 180) || "download";
const filename = `${safeBase}.${ext}`;
const asciiFallback =
filename.replace(/[^\x20-\x7e]/g, "_") || `download.${ext}`;
res.setHeader(
"Content-Disposition",
`attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(
filename,
)}`,
);
}
if (response.body) {
const nodeStream = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
nodeStream.pipe(res);
+17 -1
View File
@@ -1669,8 +1669,9 @@
"dashboard": "لوحة المعلومات",
"bookings": "حجوزات القاعات",
"external": "اللقاءات الخارجية",
"gifts": "الهدايا والدروع",
"gifts": "الإهداءات",
"issues": "طلبات الصرف",
"photos": "تصوير",
"reports": "التقارير",
"audit": "سجل النشاط",
"rooms": "القاعات"
@@ -1766,6 +1767,21 @@
"occasion": "المناسبة",
"quantity": "الكمية"
},
"photos": {
"newAlbum": "ألبوم جديد",
"editAlbum": "تعديل الألبوم",
"albumName": "اسم الألبوم",
"linkMeeting": "ربط بلقاء خارجي",
"noMeeting": "بدون ربط",
"addPhotos": "إضافة صور",
"download": "تنزيل",
"downloadAll": "تنزيل الكل",
"uploading": "جارٍ الرفع",
"emptyAlbums": "لا توجد ألبومات.",
"emptyPhotos": "لا توجد صور في هذا الألبوم.",
"confirmDeleteAlbum": "سيتم حذف الألبوم وجميع صوره. لا يمكن التراجع.",
"confirmDeletePhoto": "سيتم حذف هذه الصورة. لا يمكن التراجع."
},
"reports": {
"byRoom": "الحجوزات حسب القاعة",
"byGift": "الصرف حسب الصنف",
+17 -1
View File
@@ -1542,8 +1542,9 @@
"dashboard": "Dashboard",
"bookings": "Room Bookings",
"external": "External Meetings",
"gifts": "Gifts & Shields",
"gifts": "Dedications",
"issues": "Issuance Requests",
"photos": "Photos",
"reports": "Reports",
"audit": "Activity Log",
"rooms": "Rooms"
@@ -1639,6 +1640,21 @@
"occasion": "Occasion",
"quantity": "Quantity"
},
"photos": {
"newAlbum": "New album",
"editAlbum": "Edit album",
"albumName": "Album name",
"linkMeeting": "Link to external meeting",
"noMeeting": "No link",
"addPhotos": "Add photos",
"download": "Download",
"downloadAll": "Download all",
"uploading": "Uploading",
"emptyAlbums": "No albums yet.",
"emptyPhotos": "No photos in this album.",
"confirmDeleteAlbum": "This deletes the album and all its photos. This cannot be undone.",
"confirmDeletePhoto": "This photo will be deleted. This cannot be undone."
},
"reports": {
"byRoom": "Bookings by room",
"byGift": "Issuance by item",
@@ -4637,7 +4637,7 @@ function MeetingRow({
return (
<td
key="meeting"
className={`border border-gray-300 px-3 py-3 align-middle text-center text-[#0B1E3F] relative group`}
className={`border border-gray-300 px-3 py-3 align-middle text-center ${meeting.isExternal ? "text-[#dc2626]" : "text-[#0B1E3F]"} relative group`}
style={tintedCellStyle(col, true)}
>
<EditableCell
+483
View File
@@ -22,6 +22,11 @@ import {
List as ListIcon,
Link2,
Copy,
Camera,
Upload,
Loader2,
Download,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -54,6 +59,7 @@ import {
import { AppDock } from "@/components/app-dock";
import { useToast } from "@/hooks/use-toast";
import { apiJson, ApiError } from "@/lib/api-json";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { cn } from "@/lib/utils";
const API = `${import.meta.env.BASE_URL}api`;
@@ -185,6 +191,7 @@ type TabKey =
| "external"
| "gifts"
| "issues"
| "photos"
| "reports"
| "audit";
@@ -196,6 +203,7 @@ const TAB_SLUGS: Record<TabKey, string> = {
external: "external-meetings",
gifts: "gifts",
issues: "gift-issues",
photos: "photos",
reports: "reports",
audit: "audit",
};
@@ -501,6 +509,7 @@ export default function ProtocolPage() {
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift },
{ key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck },
{ key: "photos", label: t("protocol.tabs.photos"), icon: Camera },
{ key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 },
...(c?.canViewAudit
? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }]
@@ -1077,6 +1086,15 @@ export default function ProtocolPage() {
</section>
)}
{tab === "photos" && (
<PhotosView
canMutate={!!c?.canMutate}
isAr={isAr}
externalMeetings={external.data ?? []}
onApiError={onApiError}
/>
)}
{tab === "reports" && (
<ReportsView data={reports.data} nameOf={nameOf} t={t} />
)}
@@ -1939,3 +1957,468 @@ function IssueDialog({
</DialogShell>
);
}
// ---------------------------------------------------------------------------
// Visit photos (تصوير) — album grid + album detail with upload gallery.
// ---------------------------------------------------------------------------
type Album = {
id: number;
name: string;
externalMeetingId: number | null;
meetingTitle: string | null;
createdBy: number | null;
createdAt: string;
updatedAt: string;
photoCount: number;
coverPath: string | null;
};
type Photo = {
id: number;
albumId: number;
objectPath: string;
sortOrder: number;
uploadedBy: number | null;
createdAt: string;
};
// Build the authorized image URL from a stored `/objects/<id>` path.
const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`;
// Force-download URL for a single photo (attachment via ?download=1). The
// optional name base gets an extension appended server-side.
const photoDownloadSrc = (objectPath: string, name?: string) => {
const base = `${API}/storage${objectPath}?download=1`;
return name ? `${base}&name=${encodeURIComponent(name)}` : base;
};
// ZIP download URL for a whole album.
const albumDownloadSrc = (albumId: number) =>
`${API}/protocol/photo-albums/${albumId}/download`;
function PhotosView({
canMutate,
isAr,
externalMeetings,
onApiError,
}: {
canMutate: boolean;
isAr: boolean;
externalMeetings: ExternalMeeting[];
onApiError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [openAlbumId, setOpenAlbumId] = useState<number | null>(null);
const [albumDialog, setAlbumDialog] = useState<{
open: boolean;
edit?: Album;
}>({ open: false });
const [deleteAlbum, setDeleteAlbum] = useState<Album | null>(null);
const [deletePhoto, setDeletePhoto] = useState<Photo | null>(null);
const BackIcon = isAr ? ArrowRight : ArrowLeft;
const albums = useQuery({
queryKey: ["protocol", "photo-albums"],
queryFn: () => apiJson<Album[]>(`${API}/protocol/photo-albums`),
});
const photos = useQuery({
queryKey: ["protocol", "photos", openAlbumId],
queryFn: () =>
apiJson<Photo[]>(`${API}/protocol/photo-albums/${openAlbumId}/photos`),
enabled: openAlbumId !== null,
});
const openAlbum =
openAlbumId !== null
? (albums.data ?? []).find((a) => a.id === openAlbumId)
: undefined;
const { uploadFile, isUploading, progress } = useUpload({
basePath: `${API}/storage`,
onError: onApiError,
});
const finalize = useMutation({
mutationFn: (objectPath: string) =>
apiJson(`${API}/protocol/photo-albums/${openAlbumId}/photos`, {
method: "POST",
body: JSON.stringify({ objectPath }),
}),
onError: onApiError,
});
const handleFiles = async (files: FileList | null) => {
if (!files || openAlbumId === null) return;
for (const file of Array.from(files)) {
const resp: UploadResponse | null = await uploadFile(file);
if (resp) await finalize.mutateAsync(resp.objectPath);
}
await qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
await qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
};
const delAlbumMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photo-albums/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeleteAlbum(null);
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
const delPhotoMut = useMutation({
mutationFn: (id: number) =>
apiJson(`${API}/protocol/photos/${id}`, { method: "DELETE" }),
onSuccess: () => {
setDeletePhoto(null);
qc.invalidateQueries({ queryKey: ["protocol", "photos", openAlbumId] });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
},
onError: onApiError,
});
// --- Album detail view -----------------------------------------------------
if (openAlbum) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Button
size="sm"
variant="ghost"
onClick={() => setOpenAlbumId(null)}
>
<BackIcon size={16} />
</Button>
<div className="min-w-0">
<h2 className="font-semibold text-slate-700 truncate">
{openAlbum.name}
</h2>
{openAlbum.meetingTitle && (
<div className="text-xs text-slate-400 truncate">
<Link2 size={11} className="inline me-1" />
{openAlbum.meetingTitle}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{(photos.data?.length ?? 0) > 0 ? (
<a
href={albumDownloadSrc(openAlbum.id)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm hover:bg-slate-50 transition"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</a>
) : (
<span
aria-disabled="true"
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-400 text-sm opacity-60 cursor-not-allowed pointer-events-none"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</span>
)}
{canMutate && (
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
</div>
</div>
{photos.data && photos.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyPhotos")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(photos.data ?? []).map((p) => (
<div
key={p.id}
className="group relative aspect-square rounded-xl overflow-hidden border border-slate-200 bg-slate-100"
>
<a href={photoSrc(p.objectPath)} target="_blank" rel="noreferrer">
<img
src={photoSrc(p.objectPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
</a>
<a
href={photoDownloadSrc(
p.objectPath,
`${openAlbum.name}-${p.sortOrder + 1}`,
)}
download
title={t("protocol.photos.download")}
aria-label={t("protocol.photos.download")}
className="absolute bottom-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Download size={14} />
</a>
{canMutate && (
<button
type="button"
onClick={() => setDeletePhoto(p)}
className="absolute top-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Trash2 size={14} />
</button>
)}
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deletePhoto !== null}
onOpenChange={(o) => !o && setDeletePhoto(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeletePhoto")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deletePhoto && delPhotoMut.mutate(deletePhoto.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
// --- Album grid view -------------------------------------------------------
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-slate-700">
{t("protocol.tabs.photos")}
</h2>
{canMutate && (
<Button size="sm" onClick={() => setAlbumDialog({ open: true })}>
<Plus size={16} className="me-1" />
{t("protocol.photos.newAlbum")}
</Button>
)}
</div>
{albums.data && albums.data.length === 0 && (
<EmptyState text={t("protocol.photos.emptyAlbums")} />
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{(albums.data ?? []).map((a) => (
<div
key={a.id}
className="bg-white rounded-xl border border-slate-200 overflow-hidden"
>
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full aspect-square bg-slate-100 relative"
>
{a.coverPath ? (
<img
src={photoSrc(a.coverPath)}
alt=""
loading="lazy"
className="w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-slate-300">
<ImageIcon size={32} />
</span>
)}
<span className="absolute bottom-1 end-1 text-[11px] px-1.5 py-0.5 rounded-full bg-black/55 text-white">
{a.photoCount}
</span>
</button>
<div className="p-2">
<button
type="button"
onClick={() => setOpenAlbumId(a.id)}
className="block w-full text-start font-medium text-sm text-slate-800 truncate"
>
{a.name}
</button>
{a.meetingTitle && (
<div className="text-[11px] text-slate-400 truncate">
<Link2 size={10} className="inline me-1" />
{a.meetingTitle}
</div>
)}
{canMutate && (
<div className="flex gap-1 mt-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setAlbumDialog({ open: true, edit: a })}
>
<Pencil size={13} />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => setDeleteAlbum(a)}
>
<Trash2 size={13} className="text-rose-500" />
</Button>
</div>
)}
</div>
</div>
))}
</div>
{albumDialog.open && (
<AlbumDialog
edit={albumDialog.edit}
externalMeetings={externalMeetings}
onClose={() => setAlbumDialog({ open: false })}
onSaved={() => {
setAlbumDialog({ open: false });
qc.invalidateQueries({ queryKey: ["protocol", "photo-albums"] });
}}
onError={onApiError}
/>
)}
<AlertDialog
open={deleteAlbum !== null}
onOpenChange={(o) => !o && setDeleteAlbum(null)}
>
<AlertDialogContent dir={isAr ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("protocol.photos.confirmDeleteAlbum")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-rose-600 hover:bg-rose-700"
onClick={() => deleteAlbum && delAlbumMut.mutate(deleteAlbum.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function AlbumDialog({
edit,
externalMeetings,
onClose,
onSaved,
onError,
}: {
edit?: Album;
externalMeetings: ExternalMeeting[];
onClose: () => void;
onSaved: () => void;
onError: (e: unknown) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(edit?.name ?? "");
const [meetingId, setMeetingId] = useState<string>(
edit?.externalMeetingId != null ? String(edit.externalMeetingId) : "none",
);
const save = useMutation({
mutationFn: () => {
const payload = {
name: name.trim(),
externalMeetingId: meetingId === "none" ? null : Number(meetingId),
};
return edit
? apiJson(`${API}/protocol/photo-albums/${edit.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
: apiJson(`${API}/protocol/photo-albums`, {
method: "POST",
body: JSON.stringify(payload),
});
},
onSuccess: onSaved,
onError,
});
return (
<DialogShell
title={edit ? t("protocol.photos.editAlbum") : t("protocol.photos.newAlbum")}
onClose={onClose}
onSubmit={() => save.mutate()}
submitLabel={t("common.save")}
saving={save.isPending}
>
<div>
<Label>{t("protocol.photos.albumName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<Label>{t("protocol.photos.linkMeeting")}</Label>
<Select value={meetingId} onValueChange={setMeetingId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("protocol.photos.noMeeting")}</SelectItem>
{externalMeetings.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogShell>
);
}
+75
View File
@@ -241,6 +241,67 @@ export const protocolGiftIssuesTable = pgTable(
}),
);
// ---------------------------------------------------------------------------
// Visit photo albums (صور الزيارات) — "تصوير" tab.
//
// An album groups photographs of a visit. It may optionally be linked to an
// external protocol meeting (اللقاءات الخارجية); the link is `set null` so
// deleting the meeting never removes the album. The album cover is derived at
// query time from the first photo (by sortOrder), so there is no circular
// album <-> photo foreign key to keep in sync.
// ---------------------------------------------------------------------------
export const protocolPhotoAlbumsTable = pgTable(
"protocol_photo_albums",
{
id: serial("id").primaryKey(),
name: varchar("name", { length: 300 }).notNull(),
externalMeetingId: integer("external_meeting_id").references(
() => protocolExternalMeetingsTable.id,
{ onDelete: "set null" },
),
createdBy: integer("created_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(t) => ({
meetingIdx: index("protocol_albums_meeting_idx").on(t.externalMeetingId),
}),
);
// ---------------------------------------------------------------------------
// Photos inside an album. Each photo references a private object-storage path
// (`/objects/<id>`) served through the authorized object endpoint. Deleting an
// album cascades to its photos.
// ---------------------------------------------------------------------------
export const protocolPhotosTable = pgTable(
"protocol_photos",
{
id: serial("id").primaryKey(),
albumId: integer("album_id")
.notNull()
.references(() => protocolPhotoAlbumsTable.id, { onDelete: "cascade" }),
objectPath: varchar("object_path", { length: 500 }).notNull(),
sortOrder: integer("sort_order").notNull().default(0),
uploadedBy: integer("uploaded_by").references(() => usersTable.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
albumIdx: index("protocol_photos_album_idx").on(t.albumId),
pathIdx: index("protocol_photos_path_idx").on(t.objectPath),
}),
);
// ---------------------------------------------------------------------------
// Audit log (scoped to the protocol module only)
// ---------------------------------------------------------------------------
@@ -306,4 +367,18 @@ export type InsertProtocolGiftIssue = z.infer<
>;
export type ProtocolGiftIssue = typeof protocolGiftIssuesTable.$inferSelect;
export const insertProtocolPhotoAlbumSchema = createInsertSchema(
protocolPhotoAlbumsTable,
).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertProtocolPhotoAlbum = z.infer<
typeof insertProtocolPhotoAlbumSchema
>;
export type ProtocolPhotoAlbum = typeof protocolPhotoAlbumsTable.$inferSelect;
export const insertProtocolPhotoSchema = createInsertSchema(
protocolPhotosTable,
).omit({ id: true, createdAt: true });
export type InsertProtocolPhoto = z.infer<typeof insertProtocolPhotoSchema>;
export type ProtocolPhoto = typeof protocolPhotosTable.$inferSelect;
export type ProtocolAuditLog = typeof protocolAuditLogsTable.$inferSelect;
+378
View File
@@ -90,6 +90,9 @@ importers:
arabic-persian-reshaper:
specifier: 1.0.1
version: 1.0.1
archiver:
specifier: ^8.0.0
version: 8.0.0
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
@@ -148,6 +151,9 @@ importers:
specifier: 'catalog:'
version: 3.25.76
devDependencies:
'@types/archiver':
specifier: ^8.0.0
version: 8.0.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
@@ -2434,6 +2440,7 @@ packages:
'@smithy/core@3.24.1':
resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==}
engines: {node: '>=18.0.0'}
deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025
'@smithy/credential-provider-imds@4.3.1':
resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==}
@@ -2888,6 +2895,9 @@ packages:
'@transloadit/prettier-bytes@0.3.5':
resolution: {integrity: sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==}
'@types/archiver@8.0.0':
resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2996,6 +3006,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
@@ -3101,6 +3114,10 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -3143,6 +3160,10 @@ packages:
arabic-persian-reshaper@1.0.1:
resolution: {integrity: sha512-VYBjkhz6o4W1Xt4mD2LAReljJpLSw5CUZMqSBDIQRvFgUSlTKEYghapgBWvkeMWF4W+KF3Fm+/z8EywJU4PBeg==}
archiver@8.0.0:
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
engines: {node: '>=18'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -3153,13 +3174,69 @@ packages:
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
b4a@1.8.1:
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.9.1:
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.7.3:
resolution: {integrity: sha512-xRgplks8SvcKkdlv2M6Z2LZmRsmqd+x0nXXGXeMEjwdibj1HSDrlnqBRLeYdMvsgCox7Bq0e+DHwfczOfsn6IA==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-os@3.9.3:
resolution: {integrity: sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==}
engines: {bare: '>=1.14.0'}
bare-path@3.0.1:
resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
bare-stream@2.13.3:
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.4.5:
resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
@@ -3196,6 +3273,10 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -3211,12 +3292,19 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -3270,6 +3358,10 @@ packages:
compare-versions@6.1.1:
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
compress-commons@7.0.1:
resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==}
engines: {node: '>=18'}
connect-pg-simple@10.0.0:
resolution: {integrity: sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=22.0.0}
@@ -3303,10 +3395,22 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@7.0.1:
resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3662,12 +3766,23 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
@@ -3699,6 +3814,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -3873,6 +3991,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
@@ -3937,6 +4058,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -3984,6 +4108,10 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leven@4.1.0:
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4145,6 +4273,10 @@ packages:
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4201,6 +4333,10 @@ packages:
resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
@@ -4417,9 +4553,16 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -4592,6 +4735,17 @@ packages:
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@3.0.0:
resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==}
engines: {node: '>=18'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -4610,6 +4764,7 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: 19.1.0
react-dom: 19.1.0
@@ -4654,6 +4809,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -4771,10 +4929,19 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
streamx@2.28.0:
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -4805,6 +4972,15 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-stream@3.2.0:
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@@ -4829,6 +5005,7 @@ packages:
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -5058,6 +5235,10 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zip-stream@7.0.5:
resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==}
engines: {node: '>=18'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
@@ -7417,6 +7598,11 @@ snapshots:
'@transloadit/prettier-bytes@0.3.5': {}
'@types/archiver@8.0.0':
dependencies:
'@types/node': 25.3.5
'@types/readdir-glob': 1.1.5
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -7548,6 +7734,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 25.3.5
'@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.1':
@@ -7673,6 +7863,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -7708,6 +7902,22 @@ snapshots:
arabic-persian-reshaper@1.0.1: {}
archiver@8.0.0:
dependencies:
async: 3.2.6
buffer-crc32: 1.0.0
is-stream: 4.0.1
lazystream: 1.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
readdir-glob: 3.0.0
tar-stream: 3.2.0
zip-stream: 7.0.5
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -7721,10 +7931,49 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
async@3.2.6: {}
atomic-sleep@1.0.0: {}
b4a@1.8.1: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.9.1: {}
bare-fs@4.7.3:
dependencies:
bare-events: 2.9.1
bare-path: 3.0.1
bare-stream: 2.13.3(bare-events@2.9.1)
bare-url: 2.4.5
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-os@3.9.3: {}
bare-path@3.0.1:
dependencies:
bare-os: 3.9.3
bare-stream@2.13.3(bare-events@2.9.1):
dependencies:
b4a: 1.8.1
streamx: 2.28.0
teex: 1.0.1
optionalDependencies:
bare-events: 2.9.1
transitivePeerDependencies:
- react-native-b4a
bare-url@2.4.5:
dependencies:
bare-path: 3.0.1
base64-js@0.0.8: {}
base64-js@1.5.1: {}
@@ -7761,6 +8010,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -7781,10 +8034,17 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@@ -7835,6 +8095,14 @@ snapshots:
compare-versions@6.1.1: {}
compress-commons@7.0.1:
dependencies:
crc-32: 1.2.2
crc32-stream: 7.0.1
is-stream: 4.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
connect-pg-simple@10.0.0:
dependencies:
pg: 8.20.0
@@ -7860,11 +8128,20 @@ snapshots:
cookie@0.7.2: {}
core-util-is@1.0.3: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
crc-32@1.2.2: {}
crc32-stream@7.0.1:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -8182,10 +8459,20 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.9.1
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
@@ -8260,6 +8547,8 @@ snapshots:
fast-equals@5.4.0: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8452,6 +8741,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@7.0.5: {}
inherits@2.0.4: {}
@@ -8489,6 +8780,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
isarray@1.0.0: {}
isexe@2.0.0: {}
jiti@2.6.1: {}
@@ -8528,6 +8821,10 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
leven@4.1.0: {}
lightningcss-android-arm64@1.31.1:
@@ -8656,6 +8953,10 @@ snapshots:
minimalistic-assert@1.0.1: {}
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
@@ -8693,6 +8994,8 @@ snapshots:
nodemailer@8.0.7: {}
normalize-path@3.0.0: {}
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
@@ -8935,8 +9238,12 @@ snapshots:
dependencies:
parse-ms: 4.0.0
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
process@0.11.10: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -9132,6 +9439,28 @@ snapshots:
react@19.1.0: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@3.0.0:
dependencies:
minimatch: 10.2.5
readdirp@4.1.2: {}
readdirp@5.0.0: {}
@@ -9216,6 +9545,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
@@ -9369,8 +9700,25 @@ snapshots:
statuses@2.0.2: {}
streamx@2.28.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-argv@0.3.2: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -9391,6 +9739,30 @@ snapshots:
tapable@2.3.0: {}
tar-stream@3.2.0:
dependencies:
b4a: 1.8.1
bare-fs: 4.7.3
fast-fifo: 1.3.2
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
teex@1.0.1:
dependencies:
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.7:
dependencies:
b4a: 1.8.1
transitivePeerDependencies:
- react-native-b4a
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0
@@ -9590,6 +9962,12 @@ snapshots:
yoctocolors@2.1.2: {}
zip-stream@7.0.5:
dependencies:
compress-commons: 7.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
zod@3.25.76: {}
zod@4.3.6: {}