diff --git a/app/api/organization/[organizationId]/export/route.ts b/app/api/organization/[organizationId]/export/route.ts index 86dee6ae..e9e49c4a 100644 --- a/app/api/organization/[organizationId]/export/route.ts +++ b/app/api/organization/[organizationId]/export/route.ts @@ -11,7 +11,7 @@ import { import { MAX_ORGANIZATION_ARCHIVE_MIB, ORGANIZATION_ARCHIVE_MEDIA_TYPE, - ORGANIZATION_EXPORT_COOLDOWN_DAYS, + ORGANIZATION_EXPORT_COOLDOWN_HOURS, ORGANIZATION_EXPORT_INTENT_HEADER, ORGANIZATION_EXPORT_INTENT_VALUE, OrganizationArchiveError, @@ -54,7 +54,7 @@ function jsonError( } /** - * Verify browser intent before a request can consume the monthly export slot. + * Verify browser intent before a request can consume the export slot. * * @param request - Candidate workspace export request. * @returns True for the application's non-simple same-origin POST. @@ -128,9 +128,10 @@ export async function POST( }); } catch (error) { if (error instanceof OrganizationExportLimitError) { + const hoursLeft = Math.ceil(error.retryAfterSeconds / 3600); return jsonError( "export_limit_reached", - `You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_DAYS} days.`, + `You can generate one workspace archive every ${ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours. Try again in ${hoursLeft === 1 ? "1 hour" : `${hoursLeft} hours`}.`, 429, { "retry-after": String(error.retryAfterSeconds) }, ); diff --git a/app/settings/_components/team-manage/WorkspaceExportSection.tsx b/app/settings/_components/team-manage/WorkspaceExportSection.tsx index 665d13e9..bcaec8a0 100644 --- a/app/settings/_components/team-manage/WorkspaceExportSection.tsx +++ b/app/settings/_components/team-manage/WorkspaceExportSection.tsx @@ -4,7 +4,7 @@ import { useRef, useState, useTransition } from "react"; import { Button } from "@/components/shared/Button"; import { IconCheck, IconDoc } from "@/components/shared/icons"; import { - ORGANIZATION_EXPORT_COOLDOWN_DAYS, + ORGANIZATION_EXPORT_COOLDOWN_HOURS, ORGANIZATION_EXPORT_INTENT_HEADER, ORGANIZATION_EXPORT_INTENT_VALUE, organizationArchiveFilename, @@ -108,7 +108,7 @@ export function WorkspaceExportSection({ activity history as one JSON archive. Member accounts and other members' private notes are not included. Your own private notes are included. You can generate one archive every{" "} - {ORGANIZATION_EXPORT_COOLDOWN_DAYS} days. + {ORGANIZATION_EXPORT_COOLDOWN_HOURS} hours.

The archive can include confidential content, deleted notes, and diff --git a/lib/data/organization-portability.ts b/lib/data/organization-portability.ts index 4662b53a..17aef09d 100644 --- a/lib/data/organization-portability.ts +++ b/lib/data/organization-portability.ts @@ -36,7 +36,7 @@ import { deriveIdentifier } from "@/lib/graph/identifier"; import { MAX_ORGANIZATION_ARCHIVE_BYTES, MAX_ORGANIZATION_ARCHIVE_ROWS, - ORGANIZATION_EXPORT_COOLDOWN_DAYS, + ORGANIZATION_EXPORT_COOLDOWN_HOURS, OrganizationArchiveError, decodeOrganizationArchive, serializeOrganizationArchiveRow, @@ -155,16 +155,16 @@ async function reserveOrganizationExport( target: organizationExportLimits.userId, set: { lastStartedAt: sql`clock_timestamp()` }, setWhere: sql`${organizationExportLimits.lastStartedAt} <= clock_timestamp() - interval '${sql.raw( - String(ORGANIZATION_EXPORT_COOLDOWN_DAYS), - )} days'`, + String(ORGANIZATION_EXPORT_COOLDOWN_HOURS), + )} hours'`, }) .returning({ startedAt: organizationExportLimits.lastStartedAt }); if (reservations.length === 0) { const [limit] = await tx .select({ retryAfterSeconds: sql`GREATEST(1, CEIL(EXTRACT(EPOCH FROM (${organizationExportLimits.lastStartedAt} + interval '${sql.raw( - String(ORGANIZATION_EXPORT_COOLDOWN_DAYS), - )} days' - clock_timestamp()))))::integer`, + String(ORGANIZATION_EXPORT_COOLDOWN_HOURS), + )} hours' - clock_timestamp()))))::integer`, }) .from(organizationExportLimits) .where(eq(organizationExportLimits.userId, userId)); @@ -209,7 +209,7 @@ function assertOrganizationExportWithinBounds(raw: RawReadRows): void { } /** - * Verify ownership and size before the monthly export slot is consumed. + * Verify ownership and size before the export slot is consumed. * * @param userId - Authenticated exporting user id. * @param organizationId - Organization whose rows should be measured. diff --git a/lib/organization-portability/archive.ts b/lib/organization-portability/archive.ts index eba2558a..fb2a70f3 100644 --- a/lib/organization-portability/archive.ts +++ b/lib/organization-portability/archive.ts @@ -16,7 +16,7 @@ export { MAX_ORGANIZATION_ARCHIVE_MIB, MAX_ORGANIZATION_IMPORT_BYTES, MAX_ORGANIZATION_IMPORT_MIB, - ORGANIZATION_EXPORT_COOLDOWN_DAYS, + ORGANIZATION_EXPORT_COOLDOWN_HOURS, ORGANIZATION_EXPORT_INTENT_HEADER, ORGANIZATION_EXPORT_INTENT_VALUE, ORGANIZATION_ARCHIVE_MEDIA_TYPE, diff --git a/lib/organization-portability/client.ts b/lib/organization-portability/client.ts index da97104f..a39e20d9 100644 --- a/lib/organization-portability/client.ts +++ b/lib/organization-portability/client.ts @@ -18,7 +18,7 @@ export const MAX_ORGANIZATION_IMPORT_BYTES = MAX_ORGANIZATION_IMPORT_MIB * 1024 * 1024; /** Rolling cooldown between admitted workspace archive generations. */ -export const ORGANIZATION_EXPORT_COOLDOWN_DAYS = 30; +export const ORGANIZATION_EXPORT_COOLDOWN_HOURS = 24; /** Header required on the state-changing workspace export request. */ export const ORGANIZATION_EXPORT_INTENT_HEADER = "x-piyaz-export"; diff --git a/tests/api/organization-portability.test.ts b/tests/api/organization-portability.test.ts index 44c1f69f..52893e22 100644 --- a/tests/api/organization-portability.test.ts +++ b/tests/api/organization-portability.test.ts @@ -295,7 +295,7 @@ describe("POST /api/organization/[organizationId]/export", () => { expect(archive.projects).toHaveLength(1); }); - test("enforces one workspace export per user every 30 days", async () => { + test("enforces one workspace export per user every 24 hours", async () => { const fixture = await seedUserOrgProject("route-monthly-limit"); const [otherOrganization] = await superuserPool()<{ id: string }[]>` INSERT INTO piyaz_auth."organization" ("name", "slug", "createdAt") @@ -320,15 +320,16 @@ describe("POST /api/organization/[organizationId]/export", () => { expect(limited.status).toBe(429); expect(await limited.json()).toEqual({ code: "export_limit_reached", - error: "You can generate one workspace archive every 30 days.", + error: + "You can generate one workspace archive every 24 hours. Try again in 24 hours.", }); const retryAfter = Number(limited.headers.get("retry-after")); expect(retryAfter).toBeGreaterThan(0); - expect(retryAfter).toBeLessThanOrEqual(30 * 24 * 60 * 60); + expect(retryAfter).toBeLessThanOrEqual(24 * 60 * 60); await superuserPool()` UPDATE organization_export_limits - SET last_started_at = clock_timestamp() - interval '31 days' + SET last_started_at = clock_timestamp() - interval '25 hours' WHERE user_id = ${fixture.userId} `; const retried = await request(otherOrganization.id);