mirror of
https://github.com/hanzo-apps/dataroom.git
synced 2026-10-30 14:47:36 +00:00
The client was already @hanzo/kv; only the vocabulary was still Redis. Every module, symbol and comment now says what it connects to: lib/redis.ts -> lib/kv.ts (exports kv, lockKv) lib/redis-job-store.ts -> lib/kv-job-store.ts (KvJobStore) lib/redis-download-job-store.ts -> lib/kv-download-job-store.ts lib/files/tus-redis-locker.ts -> lib/files/tus-kv-locker.ts (KvLocker, KvLock) lib/api/domains/redis.ts -> lib/api/domains/kv.ts (getKey) lib/redis/ -> lib/kv-queue/ KV_URL is now the only endpoint variable — the REDIS_URL fallback and the redis://localhost default are gone, and the default scheme is kv://, which @hanzo/kv parses natively (utils/index.js). .env.example gained the KV_URL it never documented, and lost UPSTASH_REDIS_REST_LOCKER_URL/TOKEN: nothing has read those since the client changed — the locker opens a second connection to the same KV, so there is no second endpoint to configure. The second connection is `lockKv`, not `locker`: both tus routes already have a local `const locker = new KvLocker(...)`, so naming the client `locker` made the lock take itself as its own client. Verified against the repo's own gate (npx tsc --noEmit): 100 errors before and after, 0 introduced, compared set-wise by file:line:code. Behaviour is untouched — this is naming, one connection string, and dead config. Co-authored-by: Hanzo Dev <[email protected]>
207 lines
5.6 KiB
TypeScript
207 lines
5.6 KiB
TypeScript
import { nanoid } from "@/lib/utils";
|
|
|
|
import { kv } from "./kv";
|
|
|
|
export type DownloadJobStatus =
|
|
| "PENDING"
|
|
| "PROCESSING"
|
|
| "COMPLETED"
|
|
| "FAILED";
|
|
|
|
export interface DownloadJob {
|
|
id: string;
|
|
type: "bulk" | "folder";
|
|
status: DownloadJobStatus;
|
|
dataroomId: string;
|
|
dataroomName: string;
|
|
totalFiles: number;
|
|
processedFiles: number;
|
|
progress: number; // 0-100
|
|
downloadUrls?: string[]; // Multiple ZIPs if large (S3 presigned URLs auto-expire)
|
|
downloadS3Keys?: { bucket: string; key: string; region: string }[]; // S3 object references for on-demand presigning
|
|
error?: string;
|
|
teamId: string;
|
|
userId: string;
|
|
/** Viewer download: link this job belongs to */
|
|
linkId?: string;
|
|
/** Viewer download: viewer record id */
|
|
viewerId?: string;
|
|
/** Viewer download: email used for job list and email notification */
|
|
viewerEmail?: string;
|
|
triggerRunId?: string; // Trigger.dev run ID for cancellation
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
completedAt?: string;
|
|
expiresAt?: string; // When download links expire
|
|
emailNotification?: boolean;
|
|
emailAddress?: string;
|
|
/** Folder download: name of the folder (when type === "folder") */
|
|
folderName?: string;
|
|
}
|
|
|
|
const JOB_PREFIX = "download_job:";
|
|
const TEAM_JOBS_PREFIX = "team_download_jobs:";
|
|
const VIEWER_JOBS_PREFIX = "viewer_download_jobs:";
|
|
const JOB_TTL = 60 * 60 * 24 * 3; // 3 days - KV handles cleanup via TTL
|
|
|
|
export class KvDownloadJobStore {
|
|
private getJobKey(jobId: string): string {
|
|
return `${JOB_PREFIX}${jobId}`;
|
|
}
|
|
|
|
private getTeamJobsKey(teamId: string): string {
|
|
return `${TEAM_JOBS_PREFIX}${teamId}`;
|
|
}
|
|
|
|
private getViewerJobsKey(linkId: string, viewerEmail: string): string {
|
|
return `${VIEWER_JOBS_PREFIX}${linkId}:${viewerEmail.toLowerCase()}`;
|
|
}
|
|
|
|
async createJob(
|
|
jobData: Omit<DownloadJob, "id" | "createdAt" | "updatedAt">,
|
|
): Promise<DownloadJob> {
|
|
const jobId = nanoid();
|
|
const now = new Date().toISOString();
|
|
|
|
const job: DownloadJob = {
|
|
...jobData,
|
|
id: jobId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
const jobKey = this.getJobKey(jobId);
|
|
const teamJobsKey = this.getTeamJobsKey(jobData.teamId);
|
|
|
|
// Store job data with TTL (KV handles cleanup)
|
|
await kv.setex(jobKey, JOB_TTL, JSON.stringify(job));
|
|
|
|
// Add to team's job list (sorted by creation time)
|
|
await kv.zadd(teamJobsKey, { score: Date.now(), member: jobId });
|
|
await kv.expire(teamJobsKey, JOB_TTL);
|
|
|
|
// If viewer download, add to viewer index for getViewerJobs
|
|
if (jobData.linkId && jobData.viewerEmail) {
|
|
const viewerJobsKey = this.getViewerJobsKey(
|
|
jobData.linkId,
|
|
jobData.viewerEmail,
|
|
);
|
|
await kv.zadd(viewerJobsKey, { score: Date.now(), member: jobId });
|
|
await kv.expire(viewerJobsKey, JOB_TTL);
|
|
}
|
|
|
|
return job;
|
|
}
|
|
|
|
async getJob(jobId: string): Promise<DownloadJob | null> {
|
|
const jobKey = this.getJobKey(jobId);
|
|
const jobData = await kv.get(jobKey);
|
|
|
|
if (!jobData) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Check if data is already an object (KV client auto-parsed)
|
|
if (typeof jobData === "object") {
|
|
return jobData as DownloadJob;
|
|
}
|
|
// Otherwise parse the JSON string
|
|
return JSON.parse(jobData as string);
|
|
} catch (error) {
|
|
console.error("Error parsing download job data:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async updateJob(
|
|
jobId: string,
|
|
updates: Partial<DownloadJob>,
|
|
): Promise<DownloadJob | null> {
|
|
const job = await this.getJob(jobId);
|
|
if (!job) {
|
|
return null;
|
|
}
|
|
|
|
const updatedJob: DownloadJob = {
|
|
...job,
|
|
...updates,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const jobKey = this.getJobKey(jobId);
|
|
await kv.setex(jobKey, JOB_TTL, JSON.stringify(updatedJob));
|
|
|
|
return updatedJob;
|
|
}
|
|
|
|
private async getTeamJobs(
|
|
teamId: string,
|
|
limit: number = 20,
|
|
): Promise<DownloadJob[]> {
|
|
const teamJobsKey = this.getTeamJobsKey(teamId);
|
|
|
|
// Get job IDs sorted by creation time (newest first)
|
|
const jobIds = await kv.zrange(teamJobsKey, 0, limit - 1, { rev: true });
|
|
|
|
if (!jobIds.length) {
|
|
return [];
|
|
}
|
|
|
|
// Get all job data
|
|
const jobs = await Promise.all(
|
|
(jobIds as string[]).map((jobId: string) => this.getJob(jobId)),
|
|
);
|
|
|
|
// Filter out null values (expired jobs)
|
|
return jobs.filter(
|
|
(job: DownloadJob | null): job is DownloadJob => job !== null,
|
|
);
|
|
}
|
|
|
|
async getDataroomJobs(
|
|
dataroomId: string,
|
|
teamId: string,
|
|
limit: number = 10,
|
|
): Promise<DownloadJob[]> {
|
|
const teamJobs = await this.getTeamJobs(teamId, limit * 2); // Get more to filter
|
|
|
|
// Filter jobs by dataroom
|
|
return teamJobs
|
|
.filter((job) => {
|
|
const matchesDataroom = job.dataroomId === dataroomId;
|
|
const matchStatus = job.status !== "FAILED";
|
|
return matchesDataroom && matchStatus;
|
|
})
|
|
.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* List download jobs for a viewer (link + email). Used on viewer downloads page.
|
|
*/
|
|
async getViewerJobs(
|
|
linkId: string,
|
|
viewerEmail: string,
|
|
limit: number = 20,
|
|
): Promise<DownloadJob[]> {
|
|
const viewerJobsKey = this.getViewerJobsKey(linkId, viewerEmail);
|
|
const jobIds = await kv.zrange(viewerJobsKey, 0, limit - 1, {
|
|
rev: true,
|
|
});
|
|
|
|
if (!jobIds.length) {
|
|
return [];
|
|
}
|
|
|
|
const jobs = await Promise.all(
|
|
(jobIds as string[]).map((jobId: string) => this.getJob(jobId)),
|
|
);
|
|
|
|
return jobs.filter(
|
|
(job: DownloadJob | null): job is DownloadJob => job !== null,
|
|
);
|
|
}
|
|
}
|
|
|
|
export const downloadJobStore = new KvDownloadJobStore();
|