diff --git a/docs/api.md b/docs/api.md index b608d9d..c4fae60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,7 +11,7 @@ All API routes are SvelteKit `+server.ts` files under `src/routes/api/`. Authent ### GET /api/auth ``` -Response: { user: { id, username, phone, groupId, themePreference, autoScroll, mutedByDefault, avatarPath }, group: { id, name, inviteCode, accentColor, retentionDays, ... } } +Response: { user: { id, username, phone, groupId, themePreference, autoScroll, mutedByDefault, avatarPath, postCredits }, group: { id, name, inviteCode, accentColor, retentionDays, reciprocitySeed, reciprocityCap, reciprocityRatio, ... } } ``` ### POST /api/auth @@ -81,17 +81,17 @@ Each clip includes: id, originalUrl, title, addedByUsername, addedByAvatar, stat ### POST /api/clips ``` Request: { "url": "https://tiktok.com/...", "title": "optional caption" } -Response: { "clip": { "id", "status": "downloading", "contentType" } } (201 Created) +Response: { "clip": { "id", "status": "downloading", "contentType" }, "queued": false, "credits": 4, "queueLength": 0 } (201 Created) ``` -Triggers the download pipeline via the active provider. Requires a download provider to be configured (see Settings). Returns immediately with status `downloading`. +Triggers the download pipeline via the active provider. Requires a download provider to be configured (see Settings). Returns immediately with status `downloading`. `queued` is `true` when the clip went to the user's queue instead of posting directly (no credit available or the queue was non-empty). The credit is spent at publish time, so a failed download costs nothing. ### POST /api/clips/share Authenticated via `?token=` query parameter (iOS Shortcut token) or session cookie (web view). Allows sharing clips without a session cookie. ``` Request: { "url": "https://tiktok.com/...", "phone": "+1234567890" } -Response: { "ok": true, "clipId": "...", "status": "downloading" } (201 Created) +Response: { "success": 1, "message": "...", "clipId": "...", "queued": false, "credits": 4, "queueLength": 0 } (201 Created) ``` -Also accepts `"phones": ["+1234567890"]` (array) for legacy Shortcut backward compatibility. Records legacy share timestamp for upgrade banner tracking. +Same reciprocity rule as `POST /api/clips`: posts directly when the sharer has a credit and an empty queue, otherwise holds it in the queue. Also accepts `"phones": ["+1234567890"]` (array) for legacy Shortcut backward compatibility. Records legacy share timestamp for upgrade banner tracking. ### GET /api/clips/[id] Returns full clip detail with user context, interaction state, and metadata. @@ -161,8 +161,9 @@ Returns dismissed clips with thumbnail, platform, uploader info, and dismissal t ### POST /api/clips/[id]/watched ``` Request: { "watchPercent": 85 } (optional, 0–100) -Response: { "watched": true } +Response: { "watched": true, "credits": 3 } ``` +`credits` is the user's reciprocity balance after this watch. The first watch of another member's clip earns a credit (subject to the group's `ratio` and the launch cutoff); self-watches and repeat watches don't. ### PATCH /api/clips/[id]/watched Updates watch percent without marking the clip as watched. Only updates existing watched records — does not create new ones. Used for periodic progress tracking while the user is still viewing. @@ -245,62 +246,27 @@ Extends the trim deadline for a music clip in `pending_trim` status. The client Response: { "ok": true } ``` -## Clout (Reputation) +## Reciprocity (Credits) -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/clout` | Get user's clout score, tier, and breakdown | -| POST | `/api/clout` | Acknowledge tier change modal was shown | - -### GET /api/clout -Returns the user's clout score and tier when queue pacing is enabled. Clout is computed from the engagement on the user's last 10 eligible clips (watched by ≥75% of group). Users with fewer than 10 eligible clips default to Rising tier. -``` -Response: { - "enabled": true, - "score": 0.8, - "tier": "viral", - "tierName": "Viral", - "cooldownMinutes": 120, - "burstSize": 3, - "queueLimit": null, - "icon": "/icons/clout/viral.png", - "breakdown": [{ "clipId": "...", "score": 2 }, ...], - "nextTier": { "tier": "iconic", "tierName": "Iconic", "minScore": 1.0, "burst": 5, "queueLimit": null, "icon": "..." }, - "lastTier": "rising", - "tierChanged": true -} -``` - -**Tiers:** Fresh (<0.4) → Rising (0.4–0.6) → Viral (0.7–0.9) → Iconic (≥1.0). Each tier adjusts cooldown multiplier, burst size, and queue depth limit. - -**Per-clip scoring:** 0 = no reactions/favorites from others, 1 = reaction or favorite but no comment, 2 = reaction/favorite AND comment. Self-interactions excluded. Only clips watched by ≥75% of other group members are eligible. - -**Rank-down protection:** Rank-ups apply immediately. Rank-downs only take effect if the user has held their current tier for ≥4 days. The `cloutTierChangedAt` column tracks when the effective tier last changed. - -**Tier change detection:** The server tracks each user's last effective tier (`cloutTier`) and when it changed (`cloutTierChangedAt`). When the tier actually changes (after rank-down protection), `tierChanged: true` is returned. - -### POST /api/clout -Acknowledges that the tier change modal was shown. Updates the user's stored tier and resets the cooldown timer. -``` -Response: { "ok": true } -``` +Pacing is governed by a per-user credit balance: watching others' clips earns credits, posting spends them. See `docs/data-model.md` for the full model (including the launch cutoff that stops the pre-launch backlog from minting credits). The balance is surfaced on `GET /api/auth` (`user.postCredits`) and returned by the watch and queue-post endpoints; there is no standalone GET endpoint. ## Queue Management -Manage queued clips when share pacing is enabled. +The queue is an unlimited personal holding area. Clips wait here (`status: 'queued'`) until the user spends a credit to post them — there is no timed/automatic publishing. | Method | Path | Description | |--------|------|-------------| -| GET | `/api/queue` | List queued clips for user | +| GET | `/api/queue` | List queued clips for user (paginated) | | DELETE | `/api/queue` | Clear entire queue | | GET | `/api/queue/count` | Queued clip count | +| POST | `/api/queue/post` | Post selected queued clips to the feed | | DELETE | `/api/queue/[id]` | Cancel a queued clip | -| POST | `/api/queue/[id]/move-to-top` | Move entry to top of queue | -| PATCH | `/api/queue/reorder` | Reorder queue entries | ### GET /api/queue +Sorted newest-first by default. ``` -Response: { "queue": [{ "id", "clipId", "position", "scheduledAt", "sharesIn", "createdAt", "title", "originalUrl", "platform", "contentType", "status", "thumbnailPath" }] } +Query params: ?limit=20&offset=0&order=newest|oldest +Response: { "queue": [{ "id", "clipId", "createdAt", "title", "originalUrl", "platform", "contentType", "status", "thumbnailPath" }], "total": 12, "hasMore": true } ``` ### DELETE /api/queue @@ -314,22 +280,16 @@ Response: { "cleared": 3 } Response: { "count": 5 } ``` -### DELETE /api/queue/[id] -Cancels a single queued clip. Only the uploader can cancel. -``` -Response: { "ok": true } -``` - -### POST /api/queue/[id]/move-to-top -Moves a queue entry to position 0 (next to publish). +### POST /api/queue/post +Posts selected queued clips to the feed, spending one credit each. Only the caller's own `'queued'` entries are eligible; the server self-limits to available credits (extra IDs are skipped once credits run out). ``` -Response: { "ok": true } +Request: { "ids": ["entry-id-1", "entry-id-2"] } +Response: { "posted": ["clip-id-1", "clip-id-2"], "credits": 3, "queueLength": 0 } ``` -### PATCH /api/queue/reorder -Reorders all queue entries and recalculates scheduled publish times. +### DELETE /api/queue/[id] +Cancels a single queued clip. Only the uploader can cancel. ``` -Request: { "orderedIds": ["entry-id-1", "entry-id-2", "entry-id-3"] } Response: { "ok": true } ``` @@ -344,8 +304,7 @@ Host-only endpoints (unless noted). Requires `createdBy === currentUser`. | PATCH | `/api/group/retention` | Set retention policy | | PATCH | `/api/group/max-file-size` | Set max file size limit | | PATCH | `/api/group/platforms` | Set platform filter | -| PATCH | `/api/group/daily-share-limit` | Set daily share limit per user | -| PATCH | `/api/group/share-pacing` | Configure share pacing mode | +| PATCH | `/api/group/reciprocity` | Configure reciprocity pacing (seed/cap/ratio) | | GET | `/api/group/provider` | List download providers | | PATCH | `/api/group/provider` | Set active provider | | POST | `/api/group/provider/install` | Install a provider | @@ -389,23 +348,16 @@ Request: { "mode": "all", "platforms": [] } (mode: "all" | "allow" | "block") Response: { "platformFilterMode": "all", "platformFilterList": null } ``` -### PATCH /api/group/daily-share-limit -``` -Request: { "dailyShareLimit": 5 } (positive integer, or null to remove limit) -Response: { "dailyShareLimit": 5 } -``` - -### PATCH /api/group/share-pacing -Host-only. Configures queue-based share pacing. When switching away from `queue` mode, all queued clips are flushed to `ready`. +### PATCH /api/group/reciprocity +Host-only. Configures the watch-to-post credit economy. Partial updates merge with current values. Lowering the cap clamps existing members' balances down to it. ``` -Request: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null, "cloutEnabled": true } -Response: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null, "cloutEnabled": true } +Request: { "seed": 5, "cap": 5, "ratio": 1, "dailyPostLimit": null } +Response: { "seed": 5, "cap": 5, "ratio": 1, "dailyPostLimit": null } ``` -- `sharePacingMode`: `"off"` | `"daily_cap"` | `"queue"` -- `shareBurst`: 1–10 (clips per scheduled time slot) -- `shareCooldownMinutes`: 30 | 60 | 120 | 240 | 360 -- `dailyShareLimit`: positive integer or null -- `cloutEnabled`: boolean (enables reputation-based queue adjustments) +- `seed`: starting credits for new members (`0 ≤ seed ≤ cap`) +- `cap`: max credits a member can hold (`1`–`20`) +- `ratio`: qualifying watches needed to earn one credit (`1`–`10`) +- `dailyPostLimit`: hard ceiling on posts per rolling 24h, or `null` for unlimited (`1`–`100`) ### GET /api/group/provider ``` diff --git a/docs/architecture.md b/docs/architecture.md index 758fc54..32294e7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,8 @@ scrolly/ │ │ │ ├── auth.ts # Session management, invite code validation │ │ │ ├── push.ts # web-push wrapper, group notifications │ │ │ ├── share-limit.ts # Share pacing modes and daily limit enforcement -│ │ │ ├── queue.ts # Clip queue management (enqueue, publish, reorder) +│ │ │ ├── queue.ts # Personal holding queue (enqueue, publish, post-selected) +│ │ │ ├── credits.ts # Reciprocity credit economy (earn/spend/config/cutoff) │ │ │ ├── scheduler.ts # Retention policy enforcement (periodic cleanup) │ │ │ └── download-lock.ts # Prevents duplicate concurrent downloads │ │ ├── components/ @@ -97,10 +98,9 @@ scrolly/ │ │ │ ├── AddVideo.svelte # Add video form │ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo │ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI -│ │ │ ├── QueueSheet.svelte # Bottom sheet for viewing/reordering queued clips -│ │ │ ├── QueueCloutBanner.svelte # Clout tier banner inside QueueSheet -│ │ │ ├── CloutChangeModal.svelte # Tier change celebration modal -│ │ │ ├── CloutTipsView.svelte # Clout tips/breakdown view (tier details, scoring) +│ │ │ ├── QueueSheet.svelte # Bottom sheet: multi-select + post queued clips +│ │ │ ├── CreditDots.svelte # Tappable credits indicator (opens CreditInfoModal) +│ │ │ ├── CreditInfoModal.svelte # Explains the watch-to-post economy │ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips │ │ │ ├── MeGrid.svelte # Profile clip grid (favorites/uploads) │ │ │ ├── MeReelView.svelte # Profile reel overlay view @@ -122,15 +122,13 @@ scrolly/ │ │ │ ├── PlatformIcon.svelte # Platform logo (TikTok, IG, etc.) │ │ │ ├── InlineError.svelte │ │ │ ├── FilterBar.svelte # Feed filter tabs -│ │ │ ├── ShareLimitDots.svelte # Daily share limit indicator dots │ │ │ ├── ShortcutGuideSheet.svelte # iOS Shortcut setup guide │ │ │ ├── ShortcutUpgradeBanner.svelte # Legacy shortcut upgrade prompt │ │ │ └── settings/ │ │ │ ├── GroupNameEdit.svelte │ │ │ ├── InviteLink.svelte │ │ │ ├── MemberList.svelte -│ │ │ ├── DailyShareLimitPicker.svelte # Daily per-user share limit control -│ │ │ ├── SharePacingPicker.svelte # Share pacing mode, burst, and cooldown config +│ │ │ ├── ReciprocityPicker.svelte # Watch-to-post seed/cap/ratio config │ │ │ ├── RetentionPicker.svelte │ │ │ ├── SkippedClips.svelte # Dismissed/skipped clips viewer with restore │ │ │ ├── ClipsManager.svelte @@ -155,6 +153,7 @@ scrolly/ │ │ │ ├── homeTap.ts # Double-tap home to scroll to top │ │ │ ├── catchUpModal.ts # Catch-up modal dismissal state (12-hour cooldown) │ │ │ ├── queue.ts # Queue count store and fetch function +│ │ │ ├── credits.ts # Reciprocity credits, cap, derived feed-gate state │ │ │ ├── queueSheet.ts # Queue sheet visibility state │ │ │ ├── shortcutNudge.ts # Share shortcut install nudge │ │ │ └── shortcutUpgrade.ts # Shortcut upgrade banner state @@ -191,12 +190,12 @@ scrolly/ │ │ │ │ └── [id]/publish/+server.ts # Publish after trim │ │ │ ├── gifs/ │ │ │ ├── queue/ -│ │ │ │ ├── +server.ts # GET list / DELETE clear queue +│ │ │ │ ├── +server.ts # GET list (paginated) / DELETE clear queue │ │ │ │ ├── count/+server.ts # GET queue count -│ │ │ │ ├── reorder/+server.ts # PATCH reorder entries +│ │ │ │ ├── post/+server.ts # POST selected queued clips to feed │ │ │ │ └── [id]/+server.ts # DELETE cancel entry │ │ │ ├── group/ -│ │ │ │ ├── share-pacing/+server.ts # PATCH configure share pacing +│ │ │ │ ├── reciprocity/+server.ts # PATCH configure watch-to-post pacing │ │ │ ├── notifications/ │ │ │ │ └── [id]/+server.ts # Delete single notification │ │ │ ├── profile/ diff --git a/docs/data-model.md b/docs/data-model.md index 1b6a95f..f566131 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,11 +18,11 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | download_provider | text | Nullable. Active download provider ID. | | platform_filter_mode | text | Default `'all'`. `'all'` / `'allow'` / `'block'`. | | platform_filter_list | text | Nullable. Comma-separated list of platforms for allow/block filtering. | -| daily_share_limit | integer | Nullable. Max clips per user per calendar day. | -| share_pacing_mode | text | Default `'off'`. `'off'` / `'daily_cap'` / `'queue'`. | -| share_burst | integer | Default 2. Clips per scheduled time slot in queue mode (1–10). | -| share_cooldown_minutes | integer | Default 120. Minutes between clip groups in queue mode. | -| clout_enabled | integer | Boolean (0/1). Default 1. Enables reputation-based queue adjustments. | +| reciprocity_seed | integer | Default 5. Credits a new member starts with. | +| reciprocity_cap | integer | Default 5. Max credits a member can hold at once. | +| reciprocity_ratio | integer | Default 1. Qualifying watches needed to earn one credit. | +| reciprocity_started_at | integer | Nullable. Launch cutoff — only clips created at/after this earn watch credits (backlog stays watchable but pays out zero). Stamped at group creation / first boot after the cutoff migration. | +| daily_post_limit | integer | Nullable. Hard ceiling on posts per rolling 24h, on top of credits. Null = unlimited (default). | | shortcut_token | text | Nullable, unique. Token for iOS Shortcut clip sharing. | | shortcut_url | text | Nullable. URL for iOS Shortcut integration. | | created_by | text | FK → users.id (host/admin) | @@ -43,9 +43,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | avatar_path | text | Nullable. Path to uploaded profile picture. | | last_legacy_share_at | integer | Nullable. Unix timestamp of last legacy shortcut share. Used for upgrade banner. | | used_new_share_flow | integer | Boolean (0/1). Default 0. Tracks if user has adopted new web view share flow. | -| clout_tier | text | Nullable. Last acknowledged clout tier (for tier change detection). | -| clout_change_shown_at | integer | Nullable. Unix timestamp when tier change modal was last shown. | -| clout_tier_changed_at | integer | Nullable. Unix timestamp when the user's effective clout tier last changed. Used for rank-down protection (4-day cooldown). | +| post_credits | integer | Default 5. Reciprocity credit balance (0..group cap). Earned by watching others' clips, spent by posting. | | removed_at | integer | Nullable. Unix timestamp when removed from group. | | created_at | integer | Unix timestamp | @@ -175,11 +173,9 @@ Unique constraint on `(clip_id, user_id)` — tracks clips dismissed by users in | clip_id | text | FK → clips.id | | user_id | text | FK → users.id | | group_id | text | FK → groups.id | -| position | integer | Order in queue (0-based) | -| scheduled_at | integer | Unix timestamp when clip will be published | | created_at | integer | Unix timestamp | -Index on `(user_id, group_id)` for efficient queue lookups. +Index on `(user_id, group_id)` for efficient queue lookups. The queue is an unlimited personal holding area: clips wait here (status `'queued'`) until the user spends a credit to post them. There is no scheduled/timed publishing — posting is always a deliberate user action. ### push_subscriptions @@ -253,4 +249,6 @@ users 1──∞ verification_codes - **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group. - **Music clip trim workflow:** Music clips enter `pending_trim` status after download. The user can trim audio via the trim UI or skip trimming. If neither occurs before `trim_deadline`, the clip auto-publishes to `ready` status via the scheduler. - **Dismissed clips:** The `dismissed_clips` table tracks clips dismissed by users in the catch-up modal. Users can dismiss unwatched clips in bulk, then restore them later from the Skipped Clips viewer in settings. -- **Clout (reputation):** Computed on-demand from `reactions`, `favorites`, and `comments` tables. A user's clout score is the rolling average of per-clip engagement scores (0/1/2) for their last 10 eligible clips. Only clips watched by ≥75% of other group members are eligible. Self-interactions are excluded. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits. Rank-ups apply immediately; rank-downs require the user to have held their current tier for ≥4 days before taking effect. The `clout_enabled` flag on `groups` controls whether clout adjustments are applied. The `clout_tier` and `clout_tier_changed_at` columns on `users` track the effective tier and when it last changed. +- **Reciprocity pacing (watch-to-post):** The single pacing system. Each user holds an integer credit balance (`users.post_credits`, range `0..group.reciprocity_cap`). Watching another member's clip for the first time earns one credit (every `reciprocity_ratio` qualifying watches); self-watches and repeat watches never earn. Posting a clip to the feed spends one credit. New members seed at `reciprocity_seed`. A clip posts straight to the feed only when the uploader has a credit *and* an empty queue; otherwise it waits in `clip_queue` until posted. Credits are spent at publish time, so failed downloads cost nothing. This produces a conservation law — group-wide, total posts can't exceed total watches-of-others — so a prolific poster's ceiling is the rest of the group's output. Replaces the former clout/burst/cooldown/daily-cap systems. +- **Daily post cap (`groups.daily_post_limit`):** Optional hard ceiling (null = unlimited, the default) on how many clips a member can post to the feed per rolling 24h, layered on top of credits. Enforced at the share decision, at publish (`setClipReady`), and when posting from the queue — over-cap clips are held in the queue rather than rejected. Useful as a rollout safety valve since credits alone bound instantaneous balance but not total daily volume. +- **Launch cutoff (`groups.reciprocity_started_at`):** Only clips created at/after this timestamp mint watch credits. Clips that predate it remain fully watchable in the feed but pay out zero, so adopting reciprocity over an existing backlog can't convert that backlog into a flood of posting credits. It's stamped at group creation, and backfilled to the deploy time for any pre-existing group on the first boot after the cutoff migration. The ratio counter only advances on eligible (post-cutoff, non-self) watches. diff --git a/e2e/api/group-host.spec.ts b/e2e/api/group-host.spec.ts index affb923..c0a2f26 100644 --- a/e2e/api/group-host.spec.ts +++ b/e2e/api/group-host.spec.ts @@ -19,15 +19,13 @@ test.describe('API: host group management', () => { expect(res.ok()).toBe(true); }); - test('PATCH /api/group/share-pacing accepts queue mode', async ({ request, loggedInAsHost }) => { + test('PATCH /api/group/reciprocity updates pacing config', async ({ + request, + loggedInAsHost + }) => { expect(loggedInAsHost.userId).toBeTruthy(); - const res = await request.patch('/api/group/share-pacing', { - data: { - sharePacingMode: 'queue', - shareBurst: 2, - shareCooldownMinutes: 60, - cloutEnabled: true - } + const res = await request.patch('/api/group/reciprocity', { + data: { seed: 3, cap: 8, ratio: 2 } }); expect(res.ok()).toBe(true); }); diff --git a/e2e/api/reciprocity.spec.ts b/e2e/api/reciprocity.spec.ts new file mode 100644 index 0000000..3dc77b2 --- /dev/null +++ b/e2e/api/reciprocity.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '../fixtures/test'; +import { seedUser, seedClip } from '../helpers/seed'; +import * as schema from '../../src/lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { v4 as uuid } from 'uuid'; + +/** + * End-to-end credit lifecycle against the real server build: + * - sharing while out of credits queues the clip + * - watching another member's clip earns a credit + * - posting from the queue spends a credit and publishes + */ +test.describe('reciprocity: credit lifecycle', () => { + function setCredits(db: import('../fixtures/db').E2eDb, userId: string, n: number) { + (db as any) + .update(schema.users) + .set({ postCredits: n }) + .where(eq(schema.users.id, userId)) + .run(); + } + + test('sharing with no credits queues the clip', async ({ request, loggedIn, db }) => { + setCredits(db, loggedIn.userId, 0); + // The download pipeline needs an active provider; wire up the TEST_MODE fake one. + (db as any) + .update(schema.groups) + .set({ downloadProvider: 'fake' }) + .where(eq(schema.groups.id, loggedIn.groupId)) + .run(); + const res = await request.post('/api/clips', { + data: { url: 'https://www.tiktok.com/@x/video/recip-queue-1' } + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body.queued).toBe(true); + }); + + test('watching another member’s clip earns a credit', async ({ request, loggedIn, db }) => { + setCredits(db, loggedIn.userId, 0); + // A second member posts a clip the logged-in user can watch. + const other = seedUser(db, { groupId: loggedIn.groupId }); + const clip = seedClip(db, { + groupId: loggedIn.groupId, + addedBy: other.id, + status: 'ready' + }); + const res = await request.post(`/api/clips/${clip.id}/watched`, { data: {} }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.credits).toBe(1); + }); + + test('posting a queued clip spends a credit and clears it from the queue', async ({ + request, + loggedIn, + db + }) => { + setCredits(db, loggedIn.userId, 1); + // Seed a queued clip + holding-queue row for the logged-in user. + const clip = seedClip(db, { + groupId: loggedIn.groupId, + addedBy: loggedIn.userId, + status: 'queued' + }); + const entryId = uuid(); + (db as any) + .insert(schema.clipQueue) + .values({ + id: entryId, + clipId: clip.id, + userId: loggedIn.userId, + groupId: loggedIn.groupId, + createdAt: new Date() + }) + .run(); + + const res = await request.post('/api/queue/post', { data: { ids: [entryId] } }); + expect(res.ok()).toBe(true); + const body = await res.json(); + expect(body.posted).toContain(clip.id); + expect(body.credits).toBe(0); + + const count = await (await request.get('/api/queue/count')).json(); + expect(count.count).toBe(0); + }); +}); diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index d25db02..c59cc01 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -25,9 +25,9 @@ export interface LoginOptions { username?: string; groupName?: string; accentColor?: string; - sharePacingMode?: 'off' | 'daily_cap' | 'queue'; - dailyShareLimit?: number | null; - cloutEnabled?: boolean; + reciprocitySeed?: number; + reciprocityCap?: number; + reciprocityRatio?: number; } /** @@ -43,9 +43,9 @@ export async function loginAs( const group = seedGroup(db, { name: opts.groupName, accentColor: opts.accentColor, - sharePacingMode: opts.sharePacingMode, - dailyShareLimit: opts.dailyShareLimit, - cloutEnabled: opts.cloutEnabled + reciprocitySeed: opts.reciprocitySeed, + reciprocityCap: opts.reciprocityCap, + reciprocityRatio: opts.reciprocityRatio }); const user = seedUser(db, { groupId: group.id, diff --git a/e2e/helpers/page-listeners.ts b/e2e/helpers/page-listeners.ts index e5d628e..e072a33 100644 --- a/e2e/helpers/page-listeners.ts +++ b/e2e/helpers/page-listeners.ts @@ -58,8 +58,11 @@ export function watchPage( const url = req.url(); if (matchesIgnoreReq(url)) return; const failure = req.failure()?.errorText ?? 'unknown'; - // Filter out aborted requests — they often happen during navigation. - if (failure.includes('aborted') || failure.includes('Cancelled')) return; + // Filter out aborted/cancelled requests — they happen routinely during navigation as + // in-flight video/font loads are torn down. Match case-insensitively and both spellings: + // Chromium says "net::ERR_ABORTED"/"canceled", WebKit says "Load request cancelled". + const failureLc = failure.toLowerCase(); + if (failureLc.includes('aborted') || failureLc.includes('cancel')) return; issues.push({ kind: 'request-failed', url, diff --git a/e2e/helpers/seed.ts b/e2e/helpers/seed.ts index b237e20..d0fa431 100644 --- a/e2e/helpers/seed.ts +++ b/e2e/helpers/seed.ts @@ -43,9 +43,9 @@ function ensureFixtureMedia( export interface SeedGroupOptions { name?: string; accentColor?: string; - dailyShareLimit?: number | null; - sharePacingMode?: 'off' | 'daily_cap' | 'queue'; - cloutEnabled?: boolean; + reciprocitySeed?: number; + reciprocityCap?: number; + reciprocityRatio?: number; } export function seedGroup(db: E2eDb, opts: SeedGroupOptions = {}) { @@ -59,9 +59,10 @@ export function seedGroup(db: E2eDb, opts: SeedGroupOptions = {}) { name: opts.name ?? 'E2E Group', inviteCode, accentColor: opts.accentColor ?? 'coral', - dailyShareLimit: opts.dailyShareLimit ?? null, - sharePacingMode: opts.sharePacingMode ?? 'off', - cloutEnabled: opts.cloutEnabled ?? true, + reciprocitySeed: opts.reciprocitySeed ?? 5, + reciprocityCap: opts.reciprocityCap ?? 5, + reciprocityRatio: opts.reciprocityRatio ?? 1, + reciprocityStartedAt: new Date(0), shortcutToken, createdAt: now }) diff --git a/e2e/visual/queue-interactions.spec.ts b/e2e/visual/queue-interactions.spec.ts index 7c60e41..adb58ea 100644 --- a/e2e/visual/queue-interactions.spec.ts +++ b/e2e/visual/queue-interactions.spec.ts @@ -15,7 +15,8 @@ function snapPath(project: string, name: string) { /** * Seeds N queued clips for the logged-in user. Each goes into both `clips` and - * `clip_queue` so the QueueSheet can render them with thumbnails and counts. + * `clip_queue` so the QueueSheet can render them. clip_queue.createdAt is staggered so + * newest-first ordering is deterministic. */ function seedQueue( db: import('../fixtures/db').E2eDb, @@ -40,9 +41,7 @@ function seedQueue( clipId: c.id, userId, groupId, - position: i, - scheduledAt: new Date(now + (i + 1) * 30 * 60_000), // 30 min apart - createdAt: new Date(now) + createdAt: new Date(now - (count - i) * 60_000) }) .run(); ids.push(c.id); @@ -51,9 +50,6 @@ function seedQueue( } async function openQueueSheet(page: import('@playwright/test').Page) { - // First load the feed cleanly, then navigate to /?queue=true so the layout - // effect that listens for `?queue=true` fires after hydration. Going - // straight to /?queue=true sometimes misses the effect on first paint. await page.goto('/'); await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {}); await page.evaluate(() => { @@ -64,11 +60,7 @@ async function openQueueSheet(page: import('@playwright/test').Page) { } test.describe('queue: list renders with seeded entries', () => { - test('opening ?queue=true shows all seeded items in order', async ({ - page, - loggedIn, - db - }, testInfo) => { + test('opening ?queue=true shows all seeded items', async ({ page, loggedIn, db }, testInfo) => { seedQueue(db, loggedIn.userId, loggedIn.groupId, 4); await openQueueSheet(page); await page.waitForTimeout(600); @@ -76,11 +68,8 @@ test.describe('queue: list renders with seeded entries', () => { const items = page.locator('.queue-item'); await expect(items).toHaveCount(4, { timeout: 8_000 }); - - // Titles should be present in seeded order - for (let i = 0; i < 4; i++) { - await expect(items.nth(i)).toContainText(`queued clip #${i + 1}`); - } + // Newest-first by default → clip #4 leads. + await expect(items.first()).toContainText('queued clip #4'); }); }); @@ -97,7 +86,6 @@ test.describe('queue: cancel single entry', () => { await expect(page.locator('.queue-item')).toHaveCount(3); await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-before-cancel') }); - // Cancel the second item await page.locator('.queue-item').nth(1).locator('button[aria-label="Remove"]').click(); await page.waitForTimeout(700); await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-after-cancel') }); @@ -106,82 +94,32 @@ test.describe('queue: cancel single entry', () => { }); }); -test.describe('queue: drag-reorder via pointer events', () => { - test('dragging the handle moves items and persists via API', async ({ +test.describe('queue: multi-select post', () => { + test('selecting clips and posting publishes them and spends credits', async ({ page, loggedIn, db, request }, testInfo) => { - seedQueue(db, loggedIn.userId, loggedIn.groupId, 4); + seedQueue(db, loggedIn.userId, loggedIn.groupId, 3); await openQueueSheet(page); await page.waitForTimeout(500); - await expect(page.locator('.queue-item')).toHaveCount(4); - - await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-before-drag') }); - - // Capture initial server-side order - const before = await request.get('/api/queue'); - const beforeBody = await before.json(); - const beforeIds = (beforeBody.queue ?? []).map((q: { clipId: string }) => q.clipId); - expect(beforeIds.length).toBe(4); - - // Drag the first item's handle down past three siblings, then drop. - const firstHandle = page.locator('.queue-item').first().locator('.drag-handle'); - const lastItem = page.locator('.queue-item').nth(3); - const fromBox = await firstHandle.boundingBox(); - const toBox = await lastItem.boundingBox(); - if (!fromBox || !toBox) throw new Error('Could not measure drag handle / target'); - - const startX = fromBox.x + fromBox.width / 2; - const startY = fromBox.y + fromBox.height / 2; - const endX = toBox.x + toBox.width / 2; - const endY = toBox.y + toBox.height / 2; + await expect(page.locator('.queue-item')).toHaveCount(3); - // Synthesize pointerdown → series of pointermoves → pointerup. The handle - // uses pointer events directly (not Playwright's mouse). - const dispatch = async ( - el: import('@playwright/test').Locator, - type: string, - x: number, - y: number - ) => - el.dispatchEvent(type, { - clientX: x, - clientY: y, - pointerId: 1, - pointerType: 'mouse', - button: 0, - buttons: type === 'pointerup' ? 0 : 1, - bubbles: true, - cancelable: true - }); + // Select the first two clips. + await page.locator('.queue-item').nth(0).locator('.select-box').click(); + await page.locator('.queue-item').nth(1).locator('.select-box').click(); + await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-selected-2') }); - await dispatch(firstHandle, 'pointerdown', startX, startY); - // Several moves so the drag visibly progresses - const steps = 10; - for (let i = 1; i <= steps; i++) { - const x = startX + ((endX - startX) * i) / steps; - const y = startY + ((endY - startY) * i) / steps; - await dispatch(firstHandle, 'pointermove', x, y); - await page.waitForTimeout(40); - } - await dispatch(firstHandle, 'pointerup', endX, endY); - await page.waitForTimeout(700); - await page.screenshot({ path: snapPath(testInfo.project.name, 'queue-after-drag') }); + const postBtn = page.locator('.post-btn'); + await expect(postBtn).toContainText('Post 2'); + await postBtn.click(); + await page.waitForTimeout(800); - // Server-side order should have changed — assert the first clip moved out - // of position 0. We don't lock the exact final order because the drag - // tracking step may settle 1 position off depending on hover heuristics; - // the important test is "reorder API was actually called". - const after = await request.get('/api/queue'); - const afterBody = await after.json(); - const afterIds = (afterBody.queue ?? []).map((q: { clipId: string }) => q.clipId); - expect(afterIds.length).toBe(4); - expect( - afterIds[0], - `Expected first item to move; before=${beforeIds.join(',')}; after=${afterIds.join(',')}` - ).not.toBe(beforeIds[0]); + // Two clips left the queue (one remains). + const countRes = await request.get('/api/queue/count'); + const { count } = await countRes.json(); + expect(count).toBe(1); }); }); @@ -195,16 +133,3 @@ test.describe('queue: clear all', () => { expect(body.queue ?? []).toHaveLength(0); }); }); - -test.describe('queue: move-to-top', () => { - test('move-to-top API repositions item to position 0', async ({ request, loggedIn, db }) => { - seedQueue(db, loggedIn.userId, loggedIn.groupId, 3); - const list = await request.get('/api/queue'); - const body = await list.json(); - const last = body.queue[2]; - const move = await request.post(`/api/queue/${last.id}/move-to-top`); - expect(move.ok()).toBe(true); - const after = await (await request.get('/api/queue')).json(); - expect(after.queue[0].id).toBe(last.id); - }); -}); diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index be9ac9e..cf328f5 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -1,6 +1,7 @@ - -{#if change} - - -
- -
0 ? `translateY(${dragY}px)` : undefined} - onclick={(e) => e.stopPropagation()} - > - - -
- {#if !showTips} - {@const final = TIER_INFO[change.newTier] ?? TIER_INFO.fresh} -
-
- {#key displayTier.icon} - {displayTier.label} - {/key} -
-

{final.label}

-

- {#if isSameRank} - Your rank - {:else if isRankUp} - You ranked up! - {:else} - Your rank changed - {/if} -

-

- {#if isSameRank && change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if isSameRank && change.newTier === 'viral'} - The group loves your taste. Keep it coming. - {:else if isSameRank && change.newTier === 'rising'} - You're building momentum. Keep sharing hits. - {:else if isSameRank} - Share clips the group engages with to climb up. - {:else if change.newTier === 'iconic'} - Your clips hit different. Maximum speed unlocked. - {:else if change.newTier === 'viral' && isRankUp} - The group loves your taste. Keep it coming. - {:else if change.newTier === 'viral'} - Still solid — get more comments to climb back. - {:else if change.newTier === 'rising' && isRankUp} - You're building momentum. Nice picks. - {:else if change.newTier === 'rising'} - Share clips the group reacts to and you'll bounce back. - {:else if isRankUp} - Your clips are starting to land. - {:else} - Share clips the group engages with to climb back up. - {/if} -

-
- {formatCooldown(change.cooldownMinutes)} between clips - · - {change.burstSize} per burst - · - {change.queueLimit ?? '∞'} queue depth -
-
- - {#if change.newTier !== 'iconic'} - - {/if} - {:else if tipsData} - - {/if} -
-
-{/if} - - diff --git a/src/lib/components/CloutTipsView.svelte b/src/lib/components/CloutTipsView.svelte deleted file mode 100644 index 4d0976b..0000000 --- a/src/lib/components/CloutTipsView.svelte +++ /dev/null @@ -1,436 +0,0 @@ - - -
- {#if nextTier} - {@const currentIdx = TIER_ORDER.indexOf(currentTier)} - {@const nextIdx = TIER_ORDER.indexOf(nextTier.tier)} -
-
- {#each TIER_ORDER as tier, i (tier)} - {@const isCurrent = i === currentIdx} - {@const isNext = i === nextIdx} - {@const isPast = i < currentIdx} - - -
nextIdx} - class:selected={selectedTier === tier} - onclick={() => toggleTier(tier)} - > -
- {TIER_INFO[tier]?.label - {#if isCurrent} - YOU - {/if} -
- {TIER_INFO[tier]?.label} -
- {#if i < TIER_ORDER.length - 1} -
- {/if} - {/each} -
- {#if selectedTier} - {@const abilities = TIER_ABILITIES[selectedTier]} -
-
- {abilities.burst} - per burst -
-
-
- {formatCooldown( - Math.round(baseCooldownMinutes * abilities.cooldownMultiplier) - )} - cooldown -
-
-
- {abilities.queueLimit ?? '∞'} - queue depth -
-
- {/if} - {#if neededUpgrades > 0} -

- {neededUpgrades} clip{neededUpgrades === 1 ? '' : 's'} need better engagement to reach - {nextTier.tierName} -

- {/if} -
- -
- -
-
- - Nothing - No engagement -
-
- - Reaction - Or a favorite -
-
- - Both - Reaction + comment -
-
-
- {:else} - {@const currentTierInfo = TIER_INFO[currentTier]} -
-
- {currentTierInfo?.label -
-

You're at the top

-

Maximum speed unlocked. Keep sharing clips the group loves.

-
- {/if} -
- - diff --git a/src/lib/components/CreditDots.svelte b/src/lib/components/CreditDots.svelte new file mode 100644 index 0000000..25097cf --- /dev/null +++ b/src/lib/components/CreditDots.svelte @@ -0,0 +1,73 @@ + + + + +{#if showInfo} + (showInfo = false)} /> +{/if} + + diff --git a/src/lib/components/CreditInfoModal.svelte b/src/lib/components/CreditInfoModal.svelte new file mode 100644 index 0000000..2a02ec5 --- /dev/null +++ b/src/lib/components/CreditInfoModal.svelte @@ -0,0 +1,100 @@ + + +
e.key === 'Escape' && onclose()} +> + +
+ + diff --git a/src/lib/components/QueueCloutBanner.svelte b/src/lib/components/QueueCloutBanner.svelte deleted file mode 100644 index 04d029e..0000000 --- a/src/lib/components/QueueCloutBanner.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
-
- {clout.tierName} -
- {clout.tierName} - - {formatCooldown(clout.cooldownMinutes)} between clips · {clout.burstSize} per burst - -
-
- {#if clout.breakdown.length > 0} -
- {#each clout.breakdown as entry (entry.clipId)} - 0} class:full={entry.score === 2}> - {/each} - - {clout.breakdown.filter((b) => b.score > 0).length}/{clout.breakdown.length} got reactions - -
- {:else if clout.score === -1} - Share more clips to build your score - {/if} -
- - diff --git a/src/lib/components/QueueSheet.svelte b/src/lib/components/QueueSheet.svelte index 0555245..09aec64 100644 --- a/src/lib/components/QueueSheet.svelte +++ b/src/lib/components/QueueSheet.svelte @@ -1,28 +1,25 @@ @@ -200,21 +173,31 @@
Your Queue -
- {#if items.length > 0} - - {/if} +
+ {#if items.length > 1} + + {/if} + {#if items.length > 0} + + {/if} +
{/snippet} - {#if clout} - - {/if} - -
+
{#if loading}
Loading... @@ -223,26 +206,25 @@
No clips queued - Clips that exceed your burst will appear here + Clips you add while out of credits wait here until you post them
{:else} -
    - {#each items as item, i (item.id)} -
  • +
      + {#each items as item (item.id)} + {@const isQueued = item.status === 'queued'} + {@const isSelected = selected.has(item.id)} +
    • {#if item.thumbnailPath} @@ -256,11 +238,12 @@
      {displayTitle(item)} - {#if item.status === 'failed'} - Failed + Download failed + {:else if item.status === 'downloading'} + Downloading… {:else} - Shares in ~{item.sharesIn} + Ready to post {/if}
      @@ -274,15 +257,31 @@
    • {/each}
    + {#if loadingMore} +
    Loading…
    + {/if} {/if}
+ + {#if selected.size > 0} +
+ +
+ {/if} +{#if showInfo} + (showInfo = false)} /> +{/if} + diff --git a/src/lib/components/ShareLimitDots.svelte b/src/lib/components/ShareLimitDots.svelte deleted file mode 100644 index a2a0f7d..0000000 --- a/src/lib/components/ShareLimitDots.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/src/lib/components/UploadStatus.svelte b/src/lib/components/UploadStatus.svelte index 1fad92c..c658f85 100644 --- a/src/lib/components/UploadStatus.svelte +++ b/src/lib/components/UploadStatus.svelte @@ -6,7 +6,6 @@ import LightbulbIcon from 'phosphor-svelte/lib/LightbulbIcon'; import ScissorsIcon from 'phosphor-svelte/lib/ScissorsIcon'; import ClockIcon from 'phosphor-svelte/lib/ClockIcon'; - import ShareLimitDots from './ShareLimitDots.svelte'; const { phase, @@ -19,19 +18,13 @@ onsaveandview, ondismissnudge, ontrim, - onskiptrim, - shareCountToday, - dailyShareLimit, - queuedSharesIn + onskiptrim }: { phase: 'uploading' | 'done' | 'failed' | 'trim_prompt' | 'queued'; clipContentType: string; serverTitle: string | null; serverArtist: string | null; serverAlbumArt: string | null; - shareCountToday?: number; - dailyShareLimit?: number | null; - queuedSharesIn?: string; ondismiss: () => void; onretry: () => void; onsaveandview: () => void; @@ -43,7 +36,7 @@ function getStatusText(p: typeof phase, ct: string): string { if (p === 'uploading') return ct === 'music' ? 'Hang tight...' : 'Downloading video...'; if (p === 'trim_prompt') return 'Share the best part?'; - if (p === 'queued') return 'Queued!'; + if (p === 'queued') return 'Saved to your queue'; if (p === 'done') return 'Ready!'; return 'Download failed'; } @@ -126,25 +119,18 @@ > {:else if phase === 'queued'} - {#if queuedSharesIn} -

Your clip will share in ~{queuedSharesIn}

- {/if} - +

Post it from your queue when you're ready.

+ {:else if phase === 'done'} - {#if dailyShareLimit !== undefined && dailyShareLimit !== null && shareCountToday !== undefined} -
- -
- {/if} {#if $showShortcutNudge} @@ -388,13 +374,6 @@ text-overflow: ellipsis; } - .limit-dots-wrap { - margin-top: var(--space-md); - } - .limit-dots-wrap :global(.count-text) { - color: rgba(255, 255, 255, 0.5); - } - .primary-btn { margin-top: var(--space-xl); padding: 14px 32px; diff --git a/src/lib/components/settings/DailyShareLimitPicker.svelte b/src/lib/components/settings/DailyShareLimitPicker.svelte deleted file mode 100644 index c1c9b78..0000000 --- a/src/lib/components/settings/DailyShareLimitPicker.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - -
-
- - {#if inputValue !== ''} - per day - {/if} -
-

{description}

-
- - diff --git a/src/lib/components/settings/ReciprocityPicker.svelte b/src/lib/components/settings/ReciprocityPicker.svelte new file mode 100644 index 0000000..ebcd369 --- /dev/null +++ b/src/lib/components/settings/ReciprocityPicker.svelte @@ -0,0 +1,201 @@ + + +
+
+ Starting credits +
+ +
+
+ +
+ Max credits +
+ +
+
+ +
+ Watches per credit +
+ +
+
+ +
+ Daily post limit +
+ +
+
+ +

{description}

+
+ + diff --git a/src/lib/components/settings/SharePacingPicker.svelte b/src/lib/components/settings/SharePacingPicker.svelte deleted file mode 100644 index 04568b2..0000000 --- a/src/lib/components/settings/SharePacingPicker.svelte +++ /dev/null @@ -1,454 +0,0 @@ - - -
-
- -
- -
- - {#if mode === 'daily_cap'} -
- -
- {/if} -
- -
- - {#if mode === 'queue'} -
- -
- Queue spacing -
- {#each cooldownOptions as opt (opt.value)} - - {/each} -
-
-
-
- Reputation adjustments - Adjust pacing based on engagement -
- -
-
- {/if} -
-
- - diff --git a/src/lib/feed.ts b/src/lib/feed.ts index 189b71d..7ada4e0 100644 --- a/src/lib/feed.ts +++ b/src/lib/feed.ts @@ -1,6 +1,7 @@ import type { FeedClip } from '$lib/types'; import { fetchUnwatchedCount } from '$lib/stores/notifications'; import { clearPushNotifications } from '$lib/push'; +import { setCredits } from '$lib/stores/credits'; export type FeedFilter = 'all' | 'unwatched' | 'watched' | 'favorites' | 'uploads'; export type FeedSort = 'oldest' | 'round-robin' | 'best'; @@ -37,7 +38,16 @@ export async function fetchClips( } export async function markClipWatched(clipId: string): Promise { - await fetch(`/api/clips/${clipId}/watched`, { method: 'POST' }); + const res = await fetch(`/api/clips/${clipId}/watched`, { method: 'POST' }); + // Watching another member's clip may earn a reciprocity credit — sync the balance. + if (res.ok) { + try { + const data = await res.json(); + setCredits(data.credits); + } catch { + /* no body — ignore */ + } + } fetchUnwatchedCount(); clearPushNotifications(`new-clip-${clipId}`); } diff --git a/src/lib/server/__tests__/clout.test.ts b/src/lib/server/__tests__/clout.test.ts deleted file mode 100644 index d5709d0..0000000 --- a/src/lib/server/__tests__/clout.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { v4 as uuid } from 'uuid'; -import * as schema from '../db/schema'; - -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const { TIERS, getCloutTier, getNextTier, getEffectiveTier, getCloutScore } = - await import('../clout'); - -describe('getCloutTier', () => { - it('classifies fresh', () => { - expect(getCloutTier(0).key).toBe('fresh'); - expect(getCloutTier(0.39).key).toBe('fresh'); - }); - - it('classifies rising', () => { - expect(getCloutTier(0.4).key).toBe('rising'); - expect(getCloutTier(0.69).key).toBe('rising'); - }); - - it('classifies viral', () => { - expect(getCloutTier(0.7).key).toBe('viral'); - expect(getCloutTier(0.99).key).toBe('viral'); - }); - - it('classifies iconic at 1.0+', () => { - expect(getCloutTier(1.0).key).toBe('iconic'); - expect(getCloutTier(2.0).key).toBe('iconic'); - }); -}); - -describe('getNextTier', () => { - it('returns rising as next from fresh', () => { - expect(getNextTier('fresh')?.key).toBe('rising'); - }); - - it('returns iconic as next from viral', () => { - expect(getNextTier('viral')?.key).toBe('iconic'); - }); - - it('returns null at top tier', () => { - expect(getNextTier('iconic')).toBeNull(); - }); -}); - -describe('getEffectiveTier (rank-down protection)', () => { - const now = Date.now(); - - it('rank-up applies immediately', () => { - const r = getEffectiveTier('iconic', 'rising', new Date(now - 60_000)); - expect(r.effectiveTier).toBe('iconic'); - expect(r.tierActuallyChanged).toBe(true); - }); - - it('rank-down within 4-day cooldown keeps stored tier', () => { - const recent = new Date(now - 60_000); - const r = getEffectiveTier('fresh', 'viral', recent); - expect(r.effectiveTier).toBe('viral'); - expect(r.tierActuallyChanged).toBe(false); - }); - - it('rank-down after 4-day cooldown applies', () => { - const old = new Date(now - 5 * 24 * 60 * 60 * 1000); - const r = getEffectiveTier('fresh', 'viral', old); - expect(r.effectiveTier).toBe('fresh'); - expect(r.tierActuallyChanged).toBe(true); - }); - - it('no stored tier returns computed tier', () => { - const r = getEffectiveTier('viral', null, null); - expect(r.effectiveTier).toBe('viral'); - expect(r.tierActuallyChanged).toBe(false); - }); - - it('same tier reports no change', () => { - const r = getEffectiveTier('rising', 'rising', new Date(now)); - expect(r.effectiveTier).toBe('rising'); - expect(r.tierActuallyChanged).toBe(false); - }); -}); - -describe('getCloutScore (DB-driven)', () => { - function setupGroup(memberCount: number) { - const groupId = uuid(); - const userId = uuid(); - const now = new Date(); - (db as any) - .insert(schema.groups) - .values({ - id: groupId, - name: 'C', - inviteCode: `i-${groupId.slice(0, 6)}`, - accentColor: 'coral', - createdAt: now - }) - .run(); - (db as any) - .insert(schema.users) - .values({ - id: userId, - username: `u-${userId.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - const otherIds: string[] = []; - for (let i = 0; i < memberCount - 1; i++) { - const oid = uuid(); - otherIds.push(oid); - (db as any) - .insert(schema.users) - .values({ - id: oid, - username: `o-${oid.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - } - return { groupId, userId, otherIds }; - } - - function insertClip(groupId: string, addedBy: string) { - const id = uuid(); - (db as any) - .insert(schema.clips) - .values({ - id, - groupId, - addedBy, - originalUrl: `https://x/${id}`, - platform: 'tiktok', - status: 'ready', - contentType: 'video', - createdAt: new Date() - }) - .run(); - return id; - } - - it('returns Rising tier with score=-1 when fewer than 10 eligible clips', () => { - const { groupId, userId } = setupGroup(2); - insertClip(groupId, userId); - const result = getCloutScore(userId, groupId, 60); - expect(result.tier).toBe('rising'); - expect(result.score).toBe(-1); - }); - - it('applies cooldown multiplier from tier', () => { - const { groupId, userId } = setupGroup(2); - const result = getCloutScore(userId, groupId, 60); - // Rising default = 60 * 2.0 = 120 - expect(result.cooldownMinutes).toBe(120); - }); - - it('records tier and burst values consistent with TIERS table', () => { - const { groupId, userId } = setupGroup(2); - const result = getCloutScore(userId, groupId, 60); - expect(result.burstSize).toBe(TIERS.rising.burst); - expect(result.queueLimit).toBe(TIERS.rising.queueLimit); - }); -}); diff --git a/src/lib/server/__tests__/credits.test.ts b/src/lib/server/__tests__/credits.test.ts new file mode 100644 index 0000000..8cfc20d --- /dev/null +++ b/src/lib/server/__tests__/credits.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi } from 'vitest'; +import { v4 as uuid } from 'uuid'; +import * as schema from '../db/schema'; + +vi.mock('$lib/server/db', async () => { + const { createTestDb } = await import('../../../../tests/helpers/db'); + return createTestDb(); +}); + +const { db } = await import('$lib/server/db'); +const { + getReciprocityConfig, + getCredits, + spendCredit, + earnCredit, + grantWatchCredit, + dailyPostsRemaining +} = await import('../credits'); + +function setup( + opts: { + seed?: number; + cap?: number; + ratio?: number; + startedAt?: Date | null; + dailyPostLimit?: number | null; + } = {} +) { + const groupId = uuid(); + const userId = uuid(); + const now = new Date(); + (db as any) + .insert(schema.groups) + .values({ + id: groupId, + name: 'G', + inviteCode: `i-${groupId.slice(0, 6)}`, + accentColor: 'coral', + reciprocitySeed: opts.seed ?? 5, + reciprocityCap: opts.cap ?? 5, + reciprocityRatio: opts.ratio ?? 1, + reciprocityStartedAt: opts.startedAt ?? null, + dailyPostLimit: opts.dailyPostLimit ?? null, + createdAt: now + }) + .run(); + (db as any) + .insert(schema.users) + .values({ + id: userId, + username: `u-${userId.slice(0, 6)}`, + phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, + groupId, + postCredits: opts.seed ?? 5, + createdAt: now + }) + .run(); + return { groupId, userId }; +} + +function insertUser(groupId: string) { + const id = uuid(); + (db as any) + .insert(schema.users) + .values({ + id, + username: `u-${id.slice(0, 6)}`, + phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, + groupId, + postCredits: 5, + createdAt: new Date() + }) + .run(); + return id; +} + +function insertClip(groupId: string, addedBy: string, createdAt: Date = new Date()) { + const id = uuid(); + (db as any) + .insert(schema.clips) + .values({ + id, + groupId, + addedBy, + originalUrl: `https://x/${id}`, + platform: 'tiktok', + status: 'ready', + contentType: 'video', + createdAt + }) + .run(); + return id; +} + +function watch(clipId: string, userId: string) { + (db as any) + .insert(schema.watched) + .values({ clipId, userId, watchPercent: 100, watchedAt: new Date() }) + .run(); +} + +describe('getReciprocityConfig', () => { + it('returns the group config', () => { + const { groupId } = setup({ seed: 3, cap: 8, ratio: 2 }); + expect(getReciprocityConfig(groupId)).toEqual({ + seed: 3, + cap: 8, + ratio: 2, + startedAt: null, + dailyPostLimit: null + }); + }); + + it('falls back to 5/5/1 for an unknown group', () => { + expect(getReciprocityConfig('nope')).toEqual({ + seed: 5, + cap: 5, + ratio: 1, + startedAt: null, + dailyPostLimit: null + }); + }); +}); + +describe('spendCredit', () => { + it('decrements and returns true when credits are available', () => { + const { userId } = setup({ seed: 3 }); + expect(spendCredit(userId)).toBe(true); + expect(getCredits(userId)).toBe(2); + }); + + it('returns false and does not go negative at 0', () => { + const { userId } = setup({ seed: 0 }); + expect(spendCredit(userId)).toBe(false); + expect(getCredits(userId)).toBe(0); + }); + + it('two racing spends at balance 1 — exactly one succeeds (guard)', () => { + const { userId } = setup({ seed: 1 }); + const a = spendCredit(userId); + const b = spendCredit(userId); + expect([a, b].filter(Boolean)).toHaveLength(1); + expect(getCredits(userId)).toBe(0); + }); +}); + +describe('earnCredit', () => { + it('clamps at the cap', () => { + const { groupId, userId } = setup({ seed: 5, cap: 5 }); + earnCredit(userId, groupId); + expect(getCredits(userId)).toBe(5); // already at cap + }); + + it('increments below the cap', () => { + const { groupId, userId } = setup({ seed: 2, cap: 5 }); + expect(earnCredit(userId, groupId)).toBe(3); + }); +}); + +describe('grantWatchCredit', () => { + const RECENT = new Date(); + + it('ratio 1 earns on every qualifying watch', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1 }); + const other = insertUser(groupId); + const c = insertClip(groupId, other); + watch(c, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(1); + }); + + it('ratio 3 earns only on every third watch', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 3 }); + const other = insertUser(groupId); + // Record 3 non-self watches; only the 3rd grants. + const c1 = insertClip(groupId, other); + const c2 = insertClip(groupId, other); + const c3 = insertClip(groupId, other); + watch(c1, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(0); + watch(c2, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(0); + watch(c3, userId); + expect(grantWatchCredit(userId, groupId, RECENT)).toBe(1); + }); +}); + +describe('grantWatchCredit — launch cutoff (Path A)', () => { + const CUTOFF = new Date('2026-01-01T00:00:00Z'); + const BEFORE = new Date('2025-12-25T00:00:00Z'); + const AFTER = new Date('2026-01-02T00:00:00Z'); + + it('a pre-cutoff backlog clip earns nothing', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1, startedAt: CUTOFF }); + const other = insertUser(groupId); + const backlog = insertClip(groupId, other, BEFORE); + watch(backlog, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + }); + + it('a post-cutoff clip earns a credit', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 1, startedAt: CUTOFF }); + const other = insertUser(groupId); + const fresh = insertClip(groupId, other, AFTER); + watch(fresh, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(1); + }); + + it('pre-cutoff watches do not advance the ratio counter', () => { + const { groupId, userId } = setup({ seed: 0, cap: 5, ratio: 2, startedAt: CUTOFF }); + const other = insertUser(groupId); + // Two backlog watches earn nothing and must not count toward the ratio... + const b1 = insertClip(groupId, other, BEFORE); + const b2 = insertClip(groupId, other, BEFORE); + watch(b1, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + watch(b2, userId); + expect(grantWatchCredit(userId, groupId, BEFORE)).toBe(0); + // ...so the 2nd *eligible* watch is what triggers the ratio-2 grant. + const f1 = insertClip(groupId, other, AFTER); + const f2 = insertClip(groupId, other, AFTER); + watch(f1, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(0); + watch(f2, userId); + expect(grantWatchCredit(userId, groupId, AFTER)).toBe(1); + }); +}); + +describe('dailyPostsRemaining (daily cap)', () => { + it('returns null when the group has no daily cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: null }); + expect(dailyPostsRemaining(userId, groupId)).toBeNull(); + }); + + it('counts the user’s recent posts against the cap', () => { + const { groupId, userId } = setup({ dailyPostLimit: 3 }); + expect(dailyPostsRemaining(userId, groupId)).toBe(3); + insertClip(groupId, userId); // a 'ready' clip by the user, created now + insertClip(groupId, userId); + expect(dailyPostsRemaining(userId, groupId)).toBe(1); + }); + + it('never goes negative', () => { + const { groupId, userId } = setup({ dailyPostLimit: 1 }); + insertClip(groupId, userId); + insertClip(groupId, userId); + expect(dailyPostsRemaining(userId, groupId)).toBe(0); + }); +}); diff --git a/src/lib/server/__tests__/queue.test.ts b/src/lib/server/__tests__/queue.test.ts index 0abf315..45412b5 100644 --- a/src/lib/server/__tests__/queue.test.ts +++ b/src/lib/server/__tests__/queue.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { v4 as uuid } from 'uuid'; +import { eq } from 'drizzle-orm'; import * as schema from '../db/schema'; vi.mock('$lib/server/db', async () => { @@ -7,7 +8,7 @@ vi.mock('$lib/server/db', async () => { return createTestDb(); }); -// Stub push so queue's setClipReady → notifyNewClip doesn't try to fire VAPID. +// Stub push so queue's setClipReady / publishQueuedClip → notifyNewClip doesn't fire VAPID. vi.mock('$lib/server/push', () => ({ notifyNewClip: vi.fn(async () => {}), sendNotification: vi.fn(async () => {}) @@ -15,21 +16,18 @@ vi.mock('$lib/server/push', () => ({ const { db } = await import('$lib/server/db'); const { - checkBurstAvailable, enqueueClip, publishQueuedClip, setClipReady, getUserQueue, cancelQueuedClip, - moveToTop, - reorderQueue, clearQueue, - flushQueue, getQueueCount, - getDueQueueEntries + postQueuedClips, + removeQueueEntryByClip } = await import('../queue'); -function setup(opts: { shareBurst?: number; shareCooldownMinutes?: number } = {}) { +function setup(opts: { seed?: number; cap?: number; ratio?: number } = {}) { const groupId = uuid(); const userId = uuid(); const now = new Date(); @@ -40,8 +38,9 @@ function setup(opts: { shareBurst?: number; shareCooldownMinutes?: number } = {} name: 'G', inviteCode: `i-${groupId.slice(0, 6)}`, accentColor: 'coral', - shareBurst: opts.shareBurst ?? 1, - shareCooldownMinutes: opts.shareCooldownMinutes ?? 60, + reciprocitySeed: opts.seed ?? 5, + reciprocityCap: opts.cap ?? 5, + reciprocityRatio: opts.ratio ?? 1, createdAt: now }) .run(); @@ -52,6 +51,7 @@ function setup(opts: { shareBurst?: number; shareCooldownMinutes?: number } = {} username: `u-${userId.slice(0, 6)}`, phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, groupId, + postCredits: opts.seed ?? 5, createdAt: now }) .run(); @@ -77,224 +77,163 @@ function insertClip(groupId: string, userId: string, status: ClipStatus = 'ready return id; } -describe('checkBurstAvailable', () => { - it('reports available when no recent clips', () => { - const { groupId, userId } = setup(); - const r = checkBurstAvailable(userId, groupId, 2, 60); - expect(r.available).toBe(true); - expect(r.burstUsed).toBe(0); - expect(r.nextSlotAt).toBeNull(); - }); - - it('counts only clips within the burst window', () => { - const { groupId, userId } = setup(); - insertClip(groupId, userId); - insertClip(groupId, userId); - const r = checkBurstAvailable(userId, groupId, 2, 60); - expect(r.burstUsed).toBe(2); - expect(r.available).toBe(false); - expect(r.nextSlotAt).toBeInstanceOf(Date); - }); +function creditsOf(userId: string): number { + const [u] = (db as any) + .select({ postCredits: schema.users.postCredits }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .all(); + return u?.postCredits ?? 0; +} - it('does not count queued clips toward burst', () => { +describe('enqueueClip + getQueueCount', () => { + it('adds a clip to the holding queue', () => { const { groupId, userId } = setup(); - const cId = insertClip(groupId, userId, 'queued'); - // Add a queue entry referencing this clip - (db as any) - .insert(schema.clipQueue) - .values({ - id: uuid(), - clipId: cId, - userId, - groupId, - position: 0, - scheduledAt: new Date(Date.now() + 60_000), - createdAt: new Date() - }) - .run(); - const r = checkBurstAvailable(userId, groupId, 1, 60); - expect(r.available).toBe(true); - expect(r.burstUsed).toBe(0); + const c1 = insertClip(groupId, userId, 'queued'); + const id = enqueueClip(c1, userId, groupId); + expect(typeof id).toBe('string'); + expect(getQueueCount(userId, groupId)).toBe(1); }); -}); -describe('enqueueClip + publishQueuedClip', () => { - it('returns scheduled time and increments position', () => { + it('getUserQueue returns entries newest-first by default', () => { const { groupId, userId } = setup(); const c1 = insertClip(groupId, userId, 'queued'); const c2 = insertClip(groupId, userId, 'queued'); - const e1 = enqueueClip(c1, userId, groupId, 60, 1, 10); - const e2 = enqueueClip(c2, userId, groupId, 60, 1, 10); - expect(e1?.position).toBe(0); - expect(e2?.position).toBe(1); - expect(e2!.scheduledAt.getTime()).toBeGreaterThan(e1!.scheduledAt.getTime()); + enqueueClip(c1, userId, groupId); + enqueueClip(c2, userId, groupId); + const q = getUserQueue(userId, groupId); + expect(q).toHaveLength(2); + // Most-recently-added (c2) comes first. + expect(q[0].clipId).toBe(c2); + expect(q[1].clipId).toBe(c1); + // Legacy position/scheduledAt fields are gone. + expect((q[0] as Record).position).toBeUndefined(); }); - it('returns null when queue is full', () => { + it('getUserQueue oldest order reverses', () => { const { groupId, userId } = setup(); - const cap = 2; - for (let i = 0; i < cap; i++) { - const c = insertClip(groupId, userId, 'queued'); - expect(enqueueClip(c, userId, groupId, 60, 1, cap)).not.toBeNull(); - } - const overflow = insertClip(groupId, userId, 'queued'); - expect(enqueueClip(overflow, userId, groupId, 60, 1, cap)).toBeNull(); + const c1 = insertClip(groupId, userId, 'queued'); + const c2 = insertClip(groupId, userId, 'queued'); + enqueueClip(c1, userId, groupId); + enqueueClip(c2, userId, groupId); + const q = getUserQueue(userId, groupId, { order: 'oldest' }); + expect(q[0].clipId).toBe(c1); }); +}); - it('publishQueuedClip sets status=ready and removes from queue', () => { +describe('publishQueuedClip', () => { + it('sets status=ready and removes from queue', async () => { const { groupId, userId } = setup(); const cId = insertClip(groupId, userId, 'queued'); - const e = enqueueClip(cId, userId, groupId, 60, 1, 10)!; - expect(publishQueuedClip(e.id)).toBe(true); + const id = enqueueClip(cId, userId, groupId); + expect(await publishQueuedClip(id)).toBe(true); expect(getQueueCount(userId, groupId)).toBe(0); + const [clip] = (db as any) + .select({ status: schema.clips.status }) + .from(schema.clips) + .where(eq(schema.clips.id, cId)) + .all(); + expect(clip.status).toBe('ready'); }); - it('publishQueuedClip returns false for unknown id', () => { - expect(publishQueuedClip('not-real')).toBe(false); + it('returns false for unknown id', async () => { + expect(await publishQueuedClip('not-real')).toBe(false); }); }); describe('setClipReady', () => { - it('marks clip ready when not queued', async () => { - const { groupId, userId } = setup(); + it('marks clip ready (and spends a credit) when not queued', async () => { + const { groupId, userId } = setup({ seed: 5 }); const cId = insertClip(groupId, userId, 'downloading'); const result = await setClipReady(cId, { videoPath: 'foo.mp4' }); expect(result).toBe('ready'); + expect(creditsOf(userId)).toBe(4); // one spent }); - it('marks clip queued when in queue', async () => { - const { groupId, userId } = setup(); + it('holds the clip in the queue (no spend) when a queue row exists', async () => { + const { groupId, userId } = setup({ seed: 5 }); const cId = insertClip(groupId, userId, 'downloading'); - (db as any) - .insert(schema.clipQueue) - .values({ - id: uuid(), - clipId: cId, - userId, - groupId, - position: 0, - scheduledAt: new Date(Date.now() + 60_000), - createdAt: new Date() - }) - .run(); + enqueueClip(cId, userId, groupId); const result = await setClipReady(cId, { videoPath: 'foo.mp4' }); expect(result).toBe('queued'); + expect(creditsOf(userId)).toBe(5); // untouched + }); + + it('degrades to queued when the uploader has no credits', async () => { + const { groupId, userId } = setup({ seed: 0 }); + const cId = insertClip(groupId, userId, 'downloading'); + const result = await setClipReady(cId, { videoPath: 'foo.mp4' }); + expect(result).toBe('queued'); + expect(getQueueCount(userId, groupId)).toBe(1); }); }); -describe('queue management', () => { - it('getUserQueue returns ordered entries with clip details', () => { - const { groupId, userId } = setup(); +describe('postQueuedClips', () => { + it('publishes selected clips and spends one credit each', async () => { + const { groupId, userId } = setup({ seed: 5 }); const c1 = insertClip(groupId, userId, 'queued'); const c2 = insertClip(groupId, userId, 'queued'); - enqueueClip(c1, userId, groupId, 60, 1, 10); - enqueueClip(c2, userId, groupId, 60, 1, 10); - const q = getUserQueue(userId, groupId); - expect(q).toHaveLength(2); - expect(q[0].position).toBe(0); - expect(q[1].position).toBe(1); - }); - - it('cancelQueuedClip removes entry and soft-deletes clip', () => { - const { groupId, userId } = setup(); - const cId = insertClip(groupId, userId, 'queued'); - const e = enqueueClip(cId, userId, groupId, 60, 1, 10)!; - expect(cancelQueuedClip(e.id, userId)).toBe(true); + const e1 = enqueueClip(c1, userId, groupId); + const e2 = enqueueClip(c2, userId, groupId); + const posted = await postQueuedClips(userId, groupId, [e1, e2]); + expect(posted.sort()).toEqual([c1, c2].sort()); + expect(creditsOf(userId)).toBe(3); // two spent expect(getQueueCount(userId, groupId)).toBe(0); }); - it('cancelQueuedClip rejects when userId mismatches', () => { - const { groupId, userId } = setup(); - const cId = insertClip(groupId, userId, 'queued'); - const e = enqueueClip(cId, userId, groupId, 60, 1, 10)!; - expect(cancelQueuedClip(e.id, 'wrong-user')).toBe(false); - }); - - it('moveToTop puts target entry at position 0', () => { - const { groupId, userId } = setup(); + it('self-limits to available credits', async () => { + const { groupId, userId } = setup({ seed: 1 }); const c1 = insertClip(groupId, userId, 'queued'); const c2 = insertClip(groupId, userId, 'queued'); - const c3 = insertClip(groupId, userId, 'queued'); - enqueueClip(c1, userId, groupId, 60, 1, 10); - enqueueClip(c2, userId, groupId, 60, 1, 10); - const e3 = enqueueClip(c3, userId, groupId, 60, 1, 10)!; - expect(moveToTop(e3.id, userId, groupId)).toBe(true); - const q = getUserQueue(userId, groupId); - expect(q[0].clipId).toBe(c3); + const e1 = enqueueClip(c1, userId, groupId); + const e2 = enqueueClip(c2, userId, groupId); + const posted = await postQueuedClips(userId, groupId, [e1, e2]); + expect(posted).toHaveLength(1); // only one credit + expect(creditsOf(userId)).toBe(0); + expect(getQueueCount(userId, groupId)).toBe(1); + }); + + it("ignores entries that aren't the user's", async () => { + const { groupId, userId } = setup({ seed: 5 }); + const other = setup({ seed: 5 }); + const c1 = insertClip(other.groupId, other.userId, 'queued'); + const e1 = enqueueClip(c1, other.userId, other.groupId); + const posted = await postQueuedClips(userId, groupId, [e1]); + expect(posted).toHaveLength(0); }); +}); - it('reorderQueue applies a new ordering', () => { +describe('queue management', () => { + it('cancelQueuedClip removes entry and soft-deletes clip', () => { const { groupId, userId } = setup(); - const c1 = insertClip(groupId, userId, 'queued'); - const c2 = insertClip(groupId, userId, 'queued'); - const e1 = enqueueClip(c1, userId, groupId, 60, 1, 10)!; - const e2 = enqueueClip(c2, userId, groupId, 60, 1, 10)!; - expect(reorderQueue(userId, groupId, [e2.id, e1.id])).toBe(true); - const q = getUserQueue(userId, groupId); - expect(q[0].clipId).toBe(c2); - expect(q[1].clipId).toBe(c1); + const cId = insertClip(groupId, userId, 'queued'); + const id = enqueueClip(cId, userId, groupId); + expect(cancelQueuedClip(id, userId)).toBe(true); + expect(getQueueCount(userId, groupId)).toBe(0); }); - it('reorderQueue rejects mismatched id list', () => { + it('cancelQueuedClip rejects when userId mismatches', () => { const { groupId, userId } = setup(); - const c1 = insertClip(groupId, userId, 'queued'); - enqueueClip(c1, userId, groupId, 60, 1, 10); - expect(reorderQueue(userId, groupId, ['nope-1', 'nope-2'])).toBe(false); + const cId = insertClip(groupId, userId, 'queued'); + const id = enqueueClip(cId, userId, groupId); + expect(cancelQueuedClip(id, 'wrong-user')).toBe(false); }); it('clearQueue removes all entries and soft-deletes clips', () => { const { groupId, userId } = setup(); for (let i = 0; i < 3; i++) { const c = insertClip(groupId, userId, 'queued'); - enqueueClip(c, userId, groupId, 60, 1, 10); + enqueueClip(c, userId, groupId); } expect(clearQueue(userId, groupId)).toBe(3); expect(getQueueCount(userId, groupId)).toBe(0); }); - it('flushQueue publishes all queued clips for a group', () => { + it('removeQueueEntryByClip drops the row without touching the clip', () => { const { groupId, userId } = setup(); - const c1 = insertClip(groupId, userId, 'queued'); - const c2 = insertClip(groupId, userId, 'queued'); - enqueueClip(c1, userId, groupId, 60, 1, 10); - enqueueClip(c2, userId, groupId, 60, 1, 10); - expect(flushQueue(groupId)).toBe(2); + const cId = insertClip(groupId, userId, 'queued'); + enqueueClip(cId, userId, groupId); + removeQueueEntryByClip(cId); expect(getQueueCount(userId, groupId)).toBe(0); }); - - it('getDueQueueEntries returns only past-scheduled entries', () => { - const { groupId, userId } = setup(); - const c1 = insertClip(groupId, userId, 'queued'); - const c2 = insertClip(groupId, userId, 'queued'); - // past - (db as any) - .insert(schema.clipQueue) - .values({ - id: uuid(), - clipId: c1, - userId, - groupId, - position: 0, - scheduledAt: new Date(Date.now() - 60_000), - createdAt: new Date() - }) - .run(); - // future - (db as any) - .insert(schema.clipQueue) - .values({ - id: uuid(), - clipId: c2, - userId, - groupId, - position: 1, - scheduledAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date() - }) - .run(); - const due = getDueQueueEntries(); - const dueClipIds = due.map((d: { clipId: string }) => d.clipId); - expect(dueClipIds).toContain(c1); - expect(dueClipIds).not.toContain(c2); - }); }); diff --git a/src/lib/server/__tests__/share-limit.test.ts b/src/lib/server/__tests__/share-limit.test.ts deleted file mode 100644 index f5a62d0..0000000 --- a/src/lib/server/__tests__/share-limit.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { v4 as uuid } from 'uuid'; -import * as schema from '../db/schema'; - -// In-memory DB shared across tests in this file -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const { - getTodayStart, - checkDailyShareLimit, - formatResetTime, - formatRelativeTime, - enforceDailyShareLimit, - checkSharePacing -} = await import('../share-limit'); - -async function setupGroup() { - const groupId = uuid(); - const userId = uuid(); - const now = new Date(); - (db as any) - .insert(schema.groups) - .values({ - id: groupId, - name: 'Test', - inviteCode: `inv-${groupId.slice(0, 6)}`, - accentColor: 'coral', - createdAt: now - }) - .run(); - (db as any) - .insert(schema.users) - .values({ - id: userId, - username: `u-${userId.slice(0, 6)}`, - phone: `+1${Math.floor(2_000_000_000 + Math.random() * 7_999_999_999)}`, - groupId, - createdAt: now - }) - .run(); - return { groupId, userId }; -} - -function insertClip(groupId: string, userId: string, createdAt: Date) { - const id = uuid(); - (db as any) - .insert(schema.clips) - .values({ - id, - groupId, - addedBy: userId, - originalUrl: `https://example.com/${id}`, - platform: 'tiktok', - status: 'ready', - contentType: 'video', - createdAt - }) - .run(); - return id; -} - -describe('getTodayStart', () => { - it('returns UTC midnight when no timezone given', () => { - const start = getTodayStart(); - expect(start.getUTCHours()).toBe(0); - expect(start.getUTCMinutes()).toBe(0); - expect(start.getUTCSeconds()).toBe(0); - }); - - it('falls back to UTC midnight on invalid timezone', () => { - const start = getTodayStart('Mars/Olympus_Mons'); - expect(start.getUTCHours()).toBe(0); - }); - - it('returns a date that is the most recent past midnight', () => { - const start = getTodayStart(); - expect(start.getTime()).toBeLessThanOrEqual(Date.now()); - expect(Date.now() - start.getTime()).toBeLessThan(24 * 60 * 60 * 1000 + 1000); - }); -}); - -describe('checkDailyShareLimit', () => { - it('always allows when limit is null', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkDailyShareLimit(userId, groupId, null); - expect(result.allowed).toBe(true); - expect(result.dailyShareLimit).toBeNull(); - }); - - it('allows when count is below the limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 5); - expect(result.allowed).toBe(true); - expect(result.shareCountToday).toBe(2); - }); - - it('blocks when count is at the limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 3); - expect(result.allowed).toBe(false); - expect(result.shareCountToday).toBe(3); - }); - - it('does not count clips from other users', async () => { - const { groupId, userId } = await setupGroup(); - const other = await setupGroup(); - insertClip(other.groupId, other.userId, new Date()); - const result = checkDailyShareLimit(userId, groupId, 1); - expect(result.shareCountToday).toBe(0); - expect(result.allowed).toBe(true); - }); - - it('does not count clips from yesterday', async () => { - const { groupId, userId } = await setupGroup(); - const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000); - insertClip(groupId, userId, yesterday); - const result = checkDailyShareLimit(userId, groupId, 1); - expect(result.shareCountToday).toBe(0); - expect(result.allowed).toBe(true); - }); -}); - -describe('enforceDailyShareLimit', () => { - it('returns null response when allowed', async () => { - const { groupId, userId } = await setupGroup(); - const { response } = enforceDailyShareLimit(userId, groupId, 5); - expect(response).toBeNull(); - }); - - it('returns 429 response when limit reached', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - const { response } = enforceDailyShareLimit(userId, groupId, 1); - expect(response).not.toBeNull(); - expect(response!.status).toBe(429); - }); -}); - -describe('formatResetTime / formatRelativeTime', () => { - it('formatResetTime returns a non-empty string', () => { - expect(formatResetTime()).toMatch(/(hour|minute|second)/); - }); - - it('formatRelativeTime formats hours and minutes', () => { - expect(formatRelativeTime(2 * 3600_000 + 30 * 60_000)).toMatch(/2h 30m|2 hours/); - }); - - it('formatRelativeTime formats minutes only when < 1h', () => { - expect(formatRelativeTime(15 * 60_000)).toContain('15'); - }); -}); - -describe('checkSharePacing', () => { - it('returns mode "off" when sharePacingMode is off', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'off', - dailyShareLimit: null, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('off'); - }); - - it('returns mode "daily_cap" with allowed when under limit', async () => { - const { groupId, userId } = await setupGroup(); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'daily_cap', - dailyShareLimit: 5, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('daily_cap'); - if (result.mode === 'daily_cap') { - expect(result.response).toBeNull(); - expect(result.limitCheck.allowed).toBe(true); - } - }); - - it('returns 429 in daily_cap mode when at limit', async () => { - const { groupId, userId } = await setupGroup(); - insertClip(groupId, userId, new Date()); - insertClip(groupId, userId, new Date()); - const result = checkSharePacing(userId, groupId, { - sharePacingMode: 'daily_cap', - dailyShareLimit: 2, - shareBurst: 2, - shareCooldownMinutes: 120, - cloutEnabled: false - }); - expect(result.mode).toBe('daily_cap'); - if (result.mode === 'daily_cap') { - expect(result.response).not.toBeNull(); - expect(result.response!.status).toBe(429); - } - }); -}); diff --git a/src/lib/server/clip-download.ts b/src/lib/server/clip-download.ts index 3c64bbd..d3a649c 100644 --- a/src/lib/server/clip-download.ts +++ b/src/lib/server/clip-download.ts @@ -3,6 +3,7 @@ import { clips } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { downloadVideo } from '$lib/server/video/download'; import { downloadMusic } from '$lib/server/music/download'; +import { removeQueueEntryByClip } from '$lib/server/queue'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clip-download'); @@ -30,6 +31,8 @@ export async function startDownload( .update(clips) .set({ status: 'failed' }) .where(and(eq(clips.id, clipId), eq(clips.status, 'downloading'))); + // Drop any holding-queue row so a failed clip doesn't linger in the user's queue. + removeQueueEntryByClip(clipId); }; if (contentType === 'music') { diff --git a/src/lib/server/clout.ts b/src/lib/server/clout.ts deleted file mode 100644 index ed37c92..0000000 --- a/src/lib/server/clout.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { db } from '$lib/server/db'; -import { clips, reactions, favorites, comments, watched, users } from '$lib/server/db/schema'; -import { eq, and, ne, sql, desc, isNull } from 'drizzle-orm'; -import { createLogger } from '$lib/server/logger'; - -const log = createLogger('clout'); - -const WINDOW_SIZE = 10; -const GROUP_WATCH_THRESHOLD = 0.75; -const RANK_DOWN_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000; - -export const TIERS = { - iconic: { - name: 'Iconic', - minScore: 1.0, - cooldownMultiplier: 0.5, - burst: 5, - queueLimit: null, // uncapped - icon: '/icons/clout/iconic.png' - }, - viral: { - name: 'Viral', - minScore: 0.7, - cooldownMultiplier: 1.0, - burst: 3, - queueLimit: null, - icon: '/icons/clout/viral.png' - }, - rising: { - name: 'Rising', - minScore: 0.4, - cooldownMultiplier: 2.0, - burst: 2, - queueLimit: 10, - icon: '/icons/clout/rising.png' - }, - fresh: { - name: 'Fresh', - minScore: 0, - cooldownMultiplier: 3.0, - burst: 1, - queueLimit: 6, - icon: '/icons/clout/fresh.png' - } -} as const; - -export type TierKey = keyof typeof TIERS; - -export interface TierConfig { - name: string; - minScore: number; - cooldownMultiplier: number; - burst: number; - queueLimit: number | null; - icon: string; -} - -export interface ClipBreakdown { - clipId: string; - score: number; - title: string | null; - platform: string; - originalUrl: string; - thumbnailPath: string | null; -} - -export interface CloutResult { - score: number; - tier: TierKey; - tierConfig: TierConfig; - breakdown: ClipBreakdown[]; - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; - tierActuallyChanged: boolean; -} - -/** - * Get the clout tier for a given score. - */ -export function getCloutTier(score: number): { key: TierKey; config: TierConfig } { - if (score >= TIERS.iconic.minScore) return { key: 'iconic', config: TIERS.iconic }; - if (score >= TIERS.viral.minScore) return { key: 'viral', config: TIERS.viral }; - if (score >= TIERS.rising.minScore) return { key: 'rising', config: TIERS.rising }; - return { key: 'fresh', config: TIERS.fresh }; -} - -const TIER_ORDER: TierKey[] = ['fresh', 'rising', 'viral', 'iconic']; - -/** - * Get the next tier above the given one, or null if already at top. - */ -export function getNextTier(currentTier: TierKey): { key: TierKey; config: TierConfig } | null { - const idx = TIER_ORDER.indexOf(currentTier); - if (idx < 0 || idx >= TIER_ORDER.length - 1) return null; - const nextKey = TIER_ORDER[idx + 1]; - return { key: nextKey, config: TIERS[nextKey] }; -} - -/** - * Determine the effective tier after applying rank-down protection. - * Rank-ups apply immediately. Rank-downs only apply if the user has held - * their current tier for at least 4 days. - */ -export function getEffectiveTier( - computedTier: TierKey, - storedTier: TierKey | null, - tierChangedAt: Date | null -): { effectiveTier: TierKey; tierActuallyChanged: boolean } { - if (!storedTier) { - return { effectiveTier: computedTier, tierActuallyChanged: false }; - } - - const computedIdx = TIER_ORDER.indexOf(computedTier); - const storedIdx = TIER_ORDER.indexOf(storedTier); - - if (computedIdx >= storedIdx) { - // Rank up (or same) — apply immediately - return { - effectiveTier: computedTier, - tierActuallyChanged: computedTier !== storedTier - }; - } - - // Rank down — check 4-day stability requirement - const now = Date.now(); - const changedAt = tierChangedAt?.getTime() ?? 0; - - if (now - changedAt >= RANK_DOWN_COOLDOWN_MS) { - return { effectiveTier: computedTier, tierActuallyChanged: true }; - } - - // Still within cooldown — keep the higher tier - return { effectiveTier: storedTier, tierActuallyChanged: false }; -} - -/** - * Compute clout score for a user in a group. - * - * Scoring per clip (0 / 1 / 2): - * 0 = no reactions or favorites from others - * 1 = at least 1 reaction or favorite, but no comments from others - * 2 = at least 1 reaction/fav AND at least 1 comment from others - * - * Only clips watched by ≥75% of other group members are eligible. - * Returns the rolling average of the last 10 eligible clips. - * Users with <10 eligible clips default to Rising tier. - * - * Rank-down protection: rank-ups apply immediately, but rank-downs only - * take effect if the user has held their current tier for ≥4 days. - */ -export function getCloutScore( - userId: string, - groupId: string, - baseCooldownMinutes: number, - storedTier: TierKey | null = null, - tierChangedAt: Date | null = null -): CloutResult { - // Count active group members (not removed) - const [{ count: memberCount }] = db - .select({ count: sql`count(*)` }) - .from(users) - .where(and(eq(users.groupId, groupId), isNull(users.removedAt))) - .all(); - - // Get recent ready clips — fetch more than WINDOW_SIZE since some may be filtered out - const candidateClips = db - .select({ - id: clips.id, - title: clips.title, - platform: clips.platform, - originalUrl: clips.originalUrl, - thumbnailPath: clips.thumbnailPath - }) - .from(clips) - .where(and(eq(clips.addedBy, userId), eq(clips.groupId, groupId), eq(clips.status, 'ready'))) - .orderBy(desc(clips.createdAt)) - .limit(WINDOW_SIZE * 3) // fetch extra to account for watch threshold filtering - .all(); - - // Filter to clips watched by ≥75% of other group members - const otherMembers = memberCount - 1; - const eligibleClips = - otherMembers > 0 - ? candidateClips.filter((clip) => { - const [{ count: watchCount }] = db - .select({ count: sql`count(*)` }) - .from(watched) - .where(and(eq(watched.clipId, clip.id), ne(watched.userId, userId))) - .all(); - return watchCount / otherMembers >= GROUP_WATCH_THRESHOLD; - }) - : candidateClips; - - // Take only the most recent WINDOW_SIZE eligible clips - const windowClips = eligibleClips.slice(0, WINDOW_SIZE); - - // Not enough eligible clips — default to Rising - if (windowClips.length < WINDOW_SIZE) { - const { key, config } = getCloutTier(TIERS.rising.minScore); - const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier); - return { - score: -1, // sentinel: not enough data - tier: key, - tierConfig: config, - breakdown: [], - cooldownMinutes, - burstSize: config.burst, - queueLimit: config.queueLimit, - tierActuallyChanged: false - }; - } - - const breakdown: ClipBreakdown[] = []; - - for (const clip of windowClips) { - const clipId = clip.id; - // Count reactions from others - const [reactionResult] = db - .select({ count: sql`count(*)` }) - .from(reactions) - .where(and(eq(reactions.clipId, clipId), ne(reactions.userId, userId))) - .all(); - - // Count favorites from others - const [favResult] = db - .select({ count: sql`count(*)` }) - .from(favorites) - .where(and(eq(favorites.clipId, clipId), ne(favorites.userId, userId))) - .all(); - - // Count comments from others - const [commentResult] = db - .select({ count: sql`count(*)` }) - .from(comments) - .where(and(eq(comments.clipId, clipId), ne(comments.userId, userId))) - .all(); - - const hasReactionOrFav = (reactionResult?.count ?? 0) + (favResult?.count ?? 0) > 0; - const hasComment = (commentResult?.count ?? 0) > 0; - - let score: number; - if (hasReactionOrFav && hasComment) { - score = 2; - } else if (hasReactionOrFav) { - score = 1; - } else { - score = 0; - } - - breakdown.push({ - clipId, - score, - title: clip.title, - platform: clip.platform, - originalUrl: clip.originalUrl, - thumbnailPath: clip.thumbnailPath - }); - } - - const totalScore = breakdown.reduce((sum, b) => sum + b.score, 0); - const averageScore = totalScore / breakdown.length; - // Round to 1 decimal place - const score = Math.round(averageScore * 10) / 10; - - const { key: computedTier } = getCloutTier(score); - - // Apply rank-down protection - const { effectiveTier, tierActuallyChanged } = getEffectiveTier( - computedTier, - storedTier, - tierChangedAt - ); - const effectiveConfig = TIERS[effectiveTier]; - const cooldownMinutes = Math.round(baseCooldownMinutes * effectiveConfig.cooldownMultiplier); - - log.info( - { - userId, - score, - computedTier, - effectiveTier, - cooldownMinutes, - burst: effectiveConfig.burst, - tierActuallyChanged - }, - 'clout score computed' - ); - - return { - score, - tier: effectiveTier, - tierConfig: effectiveConfig, - breakdown, - cooldownMinutes, - burstSize: effectiveConfig.burst, - queueLimit: effectiveConfig.queueLimit, - tierActuallyChanged - }; -} diff --git a/src/lib/server/credits.ts b/src/lib/server/credits.ts new file mode 100644 index 0000000..e527da6 --- /dev/null +++ b/src/lib/server/credits.ts @@ -0,0 +1,176 @@ +import { db } from '$lib/server/db'; +import { users, groups, watched, clips } from '$lib/server/db/schema'; +import { eq, and, ne, gte, sql } from 'drizzle-orm'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('credits'); + +export interface ReciprocityConfig { + /** Credits a brand-new member starts with. */ + seed: number; + /** Maximum credits a member can hold at once. */ + cap: number; + /** How many qualifying watches earn one credit (≥1). */ + ratio: number; + /** Launch cutoff: only clips created at/after this earn credits. null = no cutoff. */ + startedAt: Date | null; + /** Hard ceiling on posts per rolling 24h, on top of credits. null = unlimited. */ + dailyPostLimit: number | null; +} + +const FALLBACK: ReciprocityConfig = { + seed: 5, + cap: 5, + ratio: 1, + startedAt: null, + dailyPostLimit: null +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Read a group's reciprocity tuning. Falls back to 5/5/1 if the group is missing. + */ +export function getReciprocityConfig(groupId: string): ReciprocityConfig { + const [group] = db + .select({ + seed: groups.reciprocitySeed, + cap: groups.reciprocityCap, + ratio: groups.reciprocityRatio, + startedAt: groups.reciprocityStartedAt, + dailyPostLimit: groups.dailyPostLimit + }) + .from(groups) + .where(eq(groups.id, groupId)) + .all(); + + if (!group) return { ...FALLBACK }; + return { + seed: group.seed, + cap: group.cap, + ratio: Math.max(1, group.ratio), + startedAt: group.startedAt ?? null, + dailyPostLimit: group.dailyPostLimit ?? null + }; +} + +/** Count clips this user has posted to the feed in the last rolling 24h. */ +export function postsInLast24h(userId: string): number { + const since = new Date(Date.now() - DAY_MS); + const [row] = db + .select({ count: sql`count(*)` }) + .from(clips) + .where(and(eq(clips.addedBy, userId), eq(clips.status, 'ready'), gte(clips.createdAt, since))) + .all(); + return row?.count ?? 0; +} + +/** + * Posts the user may still make in the current rolling 24h window, or null if the group + * has no daily cap. A hard ceiling layered on top of credits. + */ +export function dailyPostsRemaining(userId: string, groupId: string): number | null { + const { dailyPostLimit } = getReciprocityConfig(groupId); + if (dailyPostLimit === null) return null; + return Math.max(0, dailyPostLimit - postsInLast24h(userId)); +} + +/** + * Current post-credit balance for a user (0 if user is missing). + */ +export function getCredits(userId: string): number { + const [row] = db + .select({ postCredits: users.postCredits }) + .from(users) + .where(eq(users.id, userId)) + .all(); + return row?.postCredits ?? 0; +} + +/** + * Atomically spend one credit. The `post_credits >= 1` guard in the WHERE clause + * is the concurrency-safety mechanism: two racing spenders at balance 1 can never + * both succeed — exactly one UPDATE reports a changed row. + * + * Returns true if a credit was spent, false if the balance was already 0. + */ +export function spendCredit(userId: string): boolean { + const result = db + .update(users) + .set({ postCredits: sql`${users.postCredits} - 1` }) + .where(and(eq(users.id, userId), sql`${users.postCredits} >= 1`)) + .run(); + return result.changes === 1; +} + +/** + * Grant one credit, clamped to the group's cap. Returns the new balance. + */ +export function earnCredit(userId: string, groupId: string): number { + const { cap } = getReciprocityConfig(groupId); + db.update(users) + .set({ postCredits: sql`MIN(${users.postCredits} + 1, ${cap})` }) + .where(eq(users.id, userId)) + .run(); + return getCredits(userId); +} + +/** + * Refund one credit (e.g. a publish failed after the credit was spent), clamped to cap. + * Returns the new balance. + */ +export function refundCredit(userId: string, groupId: string): number { + return earnCredit(userId, groupId); +} + +/** + * Decide whether a freshly-recorded watch earns a credit, applying the group's ratio. + * + * Only call this for a NEW (first-time) watch of ANOTHER member's clip — the caller + * is responsible for excluding self-watches and repeat watches. + * + * A watch only qualifies if the clip was created at/after the group's launch cutoff + * (`reciprocityStartedAt`). Pre-cutoff backlog clips are freely watchable but mint nothing, + * so rollout can't convert the existing backlog into a flood of posting credits. + * + * With ratio 1 every qualifying watch earns. With ratio N, a credit is granted on every + * Nth qualifying watch, counted over the user's lifetime eligible (post-cutoff, non-self) + * watches (the just-inserted watch is included in the count). + * + * Returns the user's balance after any grant. + */ +export function grantWatchCredit(userId: string, groupId: string, clipCreatedAt: Date): number { + const { ratio, startedAt } = getReciprocityConfig(groupId); + + // Backlog clips (created before the launch cutoff) never earn. + if (startedAt && clipCreatedAt < startedAt) { + return getCredits(userId); + } + + if (ratio <= 1) { + const credits = earnCredit(userId, groupId); + log.info({ userId, groupId, credits }, 'watch earned credit'); + return credits; + } + + // Count this user's lifetime eligible (post-cutoff, non-self) watches, then earn on every + // Nth. Pre-cutoff watches are excluded so the ratio only advances on credit-bearing views. + const eligibility = startedAt + ? and(eq(watched.userId, userId), ne(clips.addedBy, userId), gte(clips.createdAt, startedAt)) + : and(eq(watched.userId, userId), ne(clips.addedBy, userId)); + const [row] = db + .select({ count: sql`count(*)` }) + .from(watched) + .innerJoin(clips, eq(watched.clipId, clips.id)) + .where(eligibility) + .all(); + + const qualifyingWatches = row?.count ?? 0; + if (qualifyingWatches > 0 && qualifyingWatches % ratio === 0) { + const credits = earnCredit(userId, groupId); + log.info({ userId, groupId, qualifyingWatches, ratio, credits }, 'watch earned credit'); + return credits; + } + + return getCredits(userId); +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index e59043c..e4c4b0d 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -172,6 +172,18 @@ for (const g of groupsWithoutToken) { db.update(schema.groups).set({ shortcutToken: uuid() }).where(eq(schema.groups.id, g.id)).run(); } +// Stamp the reciprocity launch cutoff for any group that predates it. This runs once on the +// first boot after the 0034 migration: the existing backlog (clips created before now) becomes +// freely watchable but earns zero credits, so rollout can't mint a flood of posting credits. +const cutoffBackfill = db + .update(schema.groups) + .set({ reciprocityStartedAt: new Date() }) + .where(isNull(schema.groups.reciprocityStartedAt)) + .run(); +if (cutoffBackfill.changes > 0) { + log.info({ groups: cutoffBackfill.changes }, 'Stamped reciprocity launch cutoff'); +} + // Remove favorites that were auto-created by negative-only reactions (👎, ❓) // A favorite is removed if the user has reactions on that clip but ALL are negative const NEGATIVE_EMOJIS = ['👎', '❓']; @@ -205,6 +217,7 @@ if (groupCount.length === 0) { name: process.env.GROUP_NAME || 'Scrolly', inviteCode, shortcutToken: uuid(), + reciprocityStartedAt: new Date(), createdAt: new Date() }) .run(); diff --git a/src/lib/server/db/migrations/0033_reciprocity_pacing.sql b/src/lib/server/db/migrations/0033_reciprocity_pacing.sql new file mode 100644 index 0000000..fe45648 --- /dev/null +++ b/src/lib/server/db/migrations/0033_reciprocity_pacing.sql @@ -0,0 +1,14 @@ +ALTER TABLE `groups` ADD `reciprocity_seed` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` ADD `reciprocity_cap` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` ADD `reciprocity_ratio` integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE `users` ADD `post_credits` integer DEFAULT 5 NOT NULL;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `daily_share_limit`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_pacing_mode`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_burst`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `share_cooldown_minutes`;--> statement-breakpoint +ALTER TABLE `groups` DROP COLUMN `clout_enabled`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_tier`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_change_shown_at`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `clout_tier_changed_at`;--> statement-breakpoint +ALTER TABLE `clip_queue` DROP COLUMN `position`;--> statement-breakpoint +ALTER TABLE `clip_queue` DROP COLUMN `scheduled_at`; diff --git a/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql b/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql new file mode 100644 index 0000000..dae06b7 --- /dev/null +++ b/src/lib/server/db/migrations/0034_reciprocity_cutoff.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `reciprocity_started_at` integer; diff --git a/src/lib/server/db/migrations/0035_daily_post_limit.sql b/src/lib/server/db/migrations/0035_daily_post_limit.sql new file mode 100644 index 0000000..2ae116a --- /dev/null +++ b/src/lib/server/db/migrations/0035_daily_post_limit.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `daily_post_limit` integer; diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index f5882c9..1664127 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -232,6 +232,27 @@ "when": 1773457298216, "tag": "0032_brown_impossible_man", "breakpoints": true + }, + { + "idx": 33, + "version": "6", + "when": 1780072362388, + "tag": "0033_reciprocity_pacing", + "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1780072362389, + "tag": "0034_reciprocity_cutoff", + "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1780072362390, + "tag": "0035_daily_post_limit", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 8074717..58ab713 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -11,11 +11,14 @@ export const groups = sqliteTable('groups', { downloadProvider: text('download_provider'), platformFilterMode: text('platform_filter_mode').notNull().default('all'), platformFilterList: text('platform_filter_list'), - dailyShareLimit: integer('daily_share_limit'), - sharePacingMode: text('share_pacing_mode').notNull().default('off'), - shareBurst: integer('share_burst').notNull().default(2), - shareCooldownMinutes: integer('share_cooldown_minutes').notNull().default(120), - cloutEnabled: integer('clout_enabled', { mode: 'boolean' }).notNull().default(true), + reciprocitySeed: integer('reciprocity_seed').notNull().default(5), + reciprocityCap: integer('reciprocity_cap').notNull().default(5), + reciprocityRatio: integer('reciprocity_ratio').notNull().default(1), + // Launch cutoff: only clips created at/after this time mint watch credits. Clips that + // predate it stay fully watchable but pay out zero, so the backlog can't be farmed. + reciprocityStartedAt: integer('reciprocity_started_at', { mode: 'timestamp' }), + // Optional hard ceiling on posts per rolling 24h, on top of credits. Null = unlimited. + dailyPostLimit: integer('daily_post_limit'), shortcutToken: text('shortcut_token').unique(), shortcutUrl: text('shortcut_url'), createdBy: text('created_by'), @@ -36,9 +39,7 @@ export const users = sqliteTable('users', { avatarPath: text('avatar_path'), lastLegacyShareAt: integer('last_legacy_share_at', { mode: 'timestamp' }), usedNewShareFlow: integer('used_new_share_flow', { mode: 'boolean' }).notNull().default(false), - cloutTier: text('clout_tier'), - cloutChangeShownAt: integer('clout_change_shown_at', { mode: 'timestamp' }), - cloutTierChangedAt: integer('clout_tier_changed_at', { mode: 'timestamp' }), + postCredits: integer('post_credits').notNull().default(5), removedAt: integer('removed_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }); @@ -234,8 +235,6 @@ export const clipQueue = sqliteTable( groupId: text('group_id') .notNull() .references(() => groups.id), - position: integer('position').notNull(), - scheduledAt: integer('scheduled_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }, (table) => [index('clip_queue_user_group').on(table.userId, table.groupId)] diff --git a/src/lib/server/queue.ts b/src/lib/server/queue.ts index 4779c6f..0f9a303 100644 --- a/src/lib/server/queue.ts +++ b/src/lib/server/queue.ts @@ -1,150 +1,33 @@ import { db } from '$lib/server/db'; -import { clips, clipQueue, groups } from '$lib/server/db/schema'; -import { eq, and, sql, desc, lte, gte } from 'drizzle-orm'; +import { clips, clipQueue } from '$lib/server/db/schema'; +import { eq, and, sql, asc, desc } from 'drizzle-orm'; import { notifyNewClip } from '$lib/server/push'; +import { spendCredit, earnCredit, dailyPostsRemaining } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; const log = createLogger('queue'); -const DEFAULT_QUEUE_DEPTH = 10; - -/** - * Check if a user still has burst slots available (instant shares). - * Uses a rolling window: burst × cooldownMinutes. - */ -export function checkBurstAvailable( - userId: string, - groupId: string, - burst: number, - cooldownMinutes: number -): { available: boolean; burstUsed: number; nextSlotAt: Date | null } { - const windowMs = burst * cooldownMinutes * 60 * 1000; - const windowStart = new Date(Date.now() - windowMs); - - const notInQueue = sql`${clips.id} NOT IN (SELECT clip_id FROM clip_queue WHERE user_id = ${userId} AND group_id = ${groupId})`; - - // Count clips this user created in the rolling window that went instant - // (i.e. clips NOT in the clip_queue table) - const [result] = db - .select({ count: sql`count(*)` }) - .from(clips) - .where( - and( - eq(clips.addedBy, userId), - eq(clips.groupId, groupId), - gte(clips.createdAt, windowStart), - notInQueue - ) - ) - .all(); - - const burstUsed = result?.count ?? 0; - const available = burstUsed < burst; - - let nextSlotAt: Date | null = null; - if (!available) { - // Find the oldest instant clip in the window — when it ages out, a slot opens - const [oldest] = db - .select({ createdAt: clips.createdAt }) - .from(clips) - .where( - and( - eq(clips.addedBy, userId), - eq(clips.groupId, groupId), - gte(clips.createdAt, windowStart), - notInQueue - ) - ) - .orderBy(clips.createdAt) - .limit(1) - .all(); - - if (oldest?.createdAt) { - nextSlotAt = new Date(oldest.createdAt.getTime() + windowMs); - } - } - - return { available, burstUsed, nextSlotAt }; -} - /** - * Add a clip to the queue. Returns the queue entry with scheduled publish time. + * Add a clip to a user's personal holding queue. The queue is unlimited — clips wait + * here (status 'queued') until the user spends a credit to post them to the feed. + * Returns the new queue entry id. */ -export function enqueueClip( - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst = 1, - queueLimit: number | null = DEFAULT_QUEUE_DEPTH -): { id: string; scheduledAt: Date; position: number } | null { - // Check queue depth - const [countResult] = db - .select({ count: sql`count(*)` }) - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .all(); - - const currentCount = countResult?.count ?? 0; - const effectiveLimit = queueLimit ?? Infinity; - if (currentCount >= effectiveLimit) { - return null; // Queue full - } - - // Find the latest scheduled_at for this user to chain from - const [latest] = db - .select({ scheduledAt: clipQueue.scheduledAt, position: clipQueue.position }) - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(desc(clipQueue.scheduledAt)) - .limit(1) - .all(); - - const now = new Date(); - const cooldownMs = cooldownMinutes * 60 * 1000; - const position = (latest?.position ?? -1) + 1; - - // Burst grouping: clips at the same burst boundary share a scheduled time. - // Position 0..burst-1 → first window, burst..2*burst-1 → second window, etc. - const isNewWindow = position % Math.max(1, burst) === 0; - let scheduledAt: Date; - if (isNewWindow || !latest?.scheduledAt) { - const baseTime = - latest?.scheduledAt && latest.scheduledAt.getTime() > now.getTime() - ? latest.scheduledAt - : now; - scheduledAt = new Date(baseTime.getTime() + cooldownMs); - } else { - // Same burst window — use the same time as the previous clip - scheduledAt = latest.scheduledAt.getTime() > now.getTime() ? latest.scheduledAt : now; - } - +export function enqueueClip(clipId: string, userId: string, groupId: string): string { const id = uuid(); - - db.insert(clipQueue) - .values({ - id, - clipId, - userId, - groupId, - position, - scheduledAt, - createdAt: now - }) - .run(); - - log.info({ clipId, userId, scheduledAt: scheduledAt.toISOString(), position }, 'clip enqueued'); - return { id, scheduledAt, position }; + db.insert(clipQueue).values({ id, clipId, userId, groupId, createdAt: new Date() }).run(); + log.info({ clipId, userId }, 'clip enqueued'); + return id; } /** - * Publish a queued clip: set status to ready, update createdAt. - * No push notification — queued clips appear silently in the feed. + * Publish a queued clip to the feed: flip status queued→ready, drop the queue row, and + * notify the rest of the group (a manual post is a real new clip for everyone else). + * Credit-spending is the caller's responsibility (see /api/queue/post). + * Returns true on success, false if the entry is missing or its clip isn't 'queued'. */ -export function publishQueuedClip(queueEntryId: string): boolean { +export async function publishQueuedClip(queueEntryId: string): Promise { const [entry] = db.select().from(clipQueue).where(eq(clipQueue.id, queueEntryId)).all(); - if (!entry) return false; const [clip] = db @@ -152,25 +35,29 @@ export function publishQueuedClip(queueEntryId: string): boolean { .from(clips) .where(eq(clips.id, entry.clipId)) .all(); - if (!clip || clip.status !== 'queued') return false; const now = new Date(); - - // Update clip: mark ready and set createdAt to now so it appears at the right feed position + // Set createdAt to now so the freshly-posted clip lands at the right feed position. db.update(clips).set({ status: 'ready', createdAt: now }).where(eq(clips.id, entry.clipId)).run(); - - // Remove from queue db.delete(clipQueue).where(eq(clipQueue.id, queueEntryId)).run(); + await notifyNewClip(entry.clipId).catch((err) => + log.error({ err, clipId: entry.clipId }, 'push notification failed') + ); + log.info({ clipId: entry.clipId, queueEntryId }, 'queued clip published'); return true; } /** * Called from the download pipeline when a clip finishes downloading. - * If the clip is in the queue, sets status to 'queued' instead of 'ready'. - * If not queued, sets status to 'ready' and fires notifications normally. + * + * - If the clip has a queue row (it was a deliberate "add to queue"), it stays held as + * 'queued' and costs nothing — the user posts it later by spending a credit. + * - Otherwise it's a direct post: spend one of the uploader's credits and go straight to + * 'ready' (notifying the group). If the uploader's credits were drained by a race since + * share time, gracefully fall back to holding it in the queue rather than posting free. * * Returns the final status that was set. */ @@ -185,7 +72,7 @@ export async function setClipReady( .all(); if (queueEntry) { - // Clip is queued — set status to 'queued', scheduler will publish later + // Deliberately queued — hold until the user posts it. db.update(clips) .set({ ...updates, status: 'queued' }) .where(eq(clips.id, clipId)) @@ -194,7 +81,32 @@ export async function setClipReady( return 'queued'; } - // Not queued — normal flow + // Direct post — needs a credit from the uploader. + const [clip] = db + .select({ addedBy: clips.addedBy, groupId: clips.groupId }) + .from(clips) + .where(eq(clips.id, clipId)) + .all(); + + // Over the daily post cap → degrade to the queue without spending a credit. + const overDailyCap = clip + ? (() => { + const remaining = dailyPostsRemaining(clip.addedBy, clip.groupId); + return remaining !== null && remaining <= 0; + })() + : false; + + if (clip && (overDailyCap || !spendCredit(clip.addedBy))) { + // No credit (or over the daily cap) — degrade to a held queue entry, never post free. + enqueueClip(clipId, clip.addedBy, clip.groupId); + db.update(clips) + .set({ ...updates, status: 'queued' }) + .where(eq(clips.id, clipId)) + .run(); + log.info({ clipId, overDailyCap }, 'direct post deferred, held in queue'); + return 'queued'; + } + db.update(clips) .set({ ...updates, status: 'ready' }) .where(eq(clips.id, clipId)) @@ -208,15 +120,23 @@ export async function setClipReady( } /** - * Get a user's queued clips with details. + * Get a user's queued clips with details, newest-first by default. `order` flips it. + * Supports lazy-load pagination via limit/offset. */ -export function getUserQueue(userId: string, groupId: string) { - return db +export function getUserQueue( + userId: string, + groupId: string, + opts: { limit?: number; offset?: number; order?: 'newest' | 'oldest' } = {} +) { + const { limit, offset = 0, order = 'newest' } = opts; + const dir = order === 'oldest' ? asc : desc; + // rowid (insertion order) is a stable tiebreak when two rows share a createdAt millisecond. + const orderBy = [dir(clipQueue.createdAt), dir(sql`clip_queue.rowid`)]; + + let q = db .select({ id: clipQueue.id, clipId: clipQueue.clipId, - position: clipQueue.position, - scheduledAt: clipQueue.scheduledAt, createdAt: clipQueue.createdAt, title: clips.title, originalUrl: clips.originalUrl, @@ -228,12 +148,15 @@ export function getUserQueue(userId: string, groupId: string) { .from(clipQueue) .innerJoin(clips, eq(clipQueue.clipId, clips.id)) .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); + .orderBy(...orderBy) + .$dynamic(); + + if (limit !== undefined) q = q.limit(limit).offset(offset); + return q.all(); } /** - * Cancel a single queued clip. Removes from queue and deletes the clip. + * Cancel a single queued clip. Removes from queue and soft-deletes the clip. */ export function cancelQueuedClip(queueEntryId: string, userId: string): boolean { const [entry] = db @@ -252,99 +175,15 @@ export function cancelQueuedClip(queueEntryId: string, userId: string): boolean } /** - * Move a queue entry to the top (position 0). Recalculate scheduled times. - */ -export function moveToTop(queueEntryId: string, userId: string, groupId: string): boolean { - const entries = db - .select() - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); - - const targetIdx = entries.findIndex((e) => e.id === queueEntryId); - if (targetIdx < 0) return false; - - // Move target to front - const [target] = entries.splice(targetIdx, 1); - entries.unshift(target); - - const [group] = db - .select({ shareCooldownMinutes: groups.shareCooldownMinutes, shareBurst: groups.shareBurst }) - .from(groups) - .where(eq(groups.id, groupId)) - .all(); - if (!group) return false; - - recalculateScheduledTimes(entries, group.shareCooldownMinutes, group.shareBurst); - return true; -} - -/** - * Reorder the queue by an ordered list of queue entry IDs. - */ -export function reorderQueue(userId: string, groupId: string, orderedIds: string[]): boolean { - const entries = db - .select() - .from(clipQueue) - .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) - .orderBy(clipQueue.position) - .all(); - - // Validate all IDs match - if (orderedIds.length !== entries.length) return false; - const entryMap = new Map(entries.map((e) => [e.id, e])); - const reordered = orderedIds.map((id) => entryMap.get(id)).filter(Boolean) as typeof entries; - if (reordered.length !== entries.length) return false; - - const [group] = db - .select({ shareCooldownMinutes: groups.shareCooldownMinutes, shareBurst: groups.shareBurst }) - .from(groups) - .where(eq(groups.id, groupId)) - .all(); - if (!group) return false; - - recalculateScheduledTimes(reordered, group.shareCooldownMinutes, group.shareBurst); - return true; -} - -/** - * Recalculate positions and scheduled_at for a reordered list of queue entries. + * Remove a queue row for a clip without touching the clip (used to clean up after a + * failed download so the orphan entry doesn't linger). */ -function recalculateScheduledTimes( - entries: { id: string; scheduledAt: Date }[], - cooldownMinutes: number, - burst = 1 -) { - const cooldownMs = cooldownMinutes * 60 * 1000; - const now = new Date(); - const safeBurst = Math.max(1, burst); - - for (let i = 0; i < entries.length; i++) { - const isNewWindow = i % safeBurst === 0; - let scheduledAt: Date; - - if (i === 0) { - scheduledAt = new Date(now.getTime() + cooldownMs); - } else if (isNewWindow) { - const baseTime = entries[i - 1].scheduledAt; - scheduledAt = new Date(Math.max(baseTime.getTime(), now.getTime()) + cooldownMs); - } else { - // Same burst window — share the previous entry's time - scheduledAt = entries[i - 1].scheduledAt; - } - - entries[i].scheduledAt = scheduledAt; - - db.update(clipQueue) - .set({ position: i, scheduledAt }) - .where(eq(clipQueue.id, entries[i].id)) - .run(); - } +export function removeQueueEntryByClip(clipId: string): void { + db.delete(clipQueue).where(eq(clipQueue.clipId, clipId)).run(); } /** - * Clear the entire queue for a user. Deletes all queued clips. + * Clear the entire queue for a user. Soft-deletes all queued clips. */ export function clearQueue(userId: string, groupId: string): number { const entries = db @@ -368,39 +207,44 @@ export function clearQueue(userId: string, groupId: string): number { } /** - * Flush all queued clips for a group (publish immediately). - * Used when switching away from queue pacing mode. + * Post selected queued clips to the feed, spending one credit each. The loop self-limits + * to the user's available credits via the atomic spend guard. Returns the clip IDs posted. + * + * `entryIds` are clip_queue entry IDs; only the caller's own 'queued' entries are posted. */ -export function flushQueue(groupId: string): number { - const entries = db - .select({ - id: clipQueue.id, - clipId: clipQueue.clipId - }) +export async function postQueuedClips( + userId: string, + groupId: string, + entryIds: string[] +): Promise { + const rows = db + .select({ id: clipQueue.id, clipId: clipQueue.clipId, status: clips.status }) .from(clipQueue) .innerJoin(clips, eq(clipQueue.clipId, clips.id)) - .where(and(eq(clipQueue.groupId, groupId), eq(clips.status, 'queued'))) + .where(and(eq(clipQueue.userId, userId), eq(clipQueue.groupId, groupId))) .all(); - if (entries.length === 0) { - // Still clean up any queue entries for non-queued clips - db.delete(clipQueue).where(eq(clipQueue.groupId, groupId)).run(); - return 0; - } + const byId = new Map(rows.map((r) => [r.id, r])); + const posted: string[] = []; + // Hard daily ceiling on top of credits (null = unlimited). + const remaining = dailyPostsRemaining(userId, groupId); - const now = new Date(); + for (const entryId of entryIds) { + if (remaining !== null && posted.length >= remaining) break; // daily cap reached + const row = byId.get(entryId); + if (!row || row.status !== 'queued') continue; // ownership + state guard + if (!spendCredit(userId)) break; // out of credits — stop - for (const entry of entries) { - db.update(clips) - .set({ status: 'ready', createdAt: now }) - .where(eq(clips.id, entry.clipId)) - .run(); + const ok = await publishQueuedClip(entryId); + if (ok) { + posted.push(row.clipId); + } else { + // Publish failed after a successful spend — refund to stay consistent. + earnCredit(userId, groupId); + } } - db.delete(clipQueue).where(eq(clipQueue.groupId, groupId)).run(); - - log.info({ groupId, published: entries.length }, 'queue flushed'); - return entries.length; + return posted; } /** @@ -415,21 +259,3 @@ export function getQueueCount(userId: string, groupId: string): number { return result?.count ?? 0; } - -/** - * Get queue entries that are due to be published. - */ -export function getDueQueueEntries() { - const now = new Date(); - return db - .select({ - id: clipQueue.id, - clipId: clipQueue.clipId, - userId: clipQueue.userId, - groupId: clipQueue.groupId, - scheduledAt: clipQueue.scheduledAt - }) - .from(clipQueue) - .where(lte(clipQueue.scheduledAt, now)) - .all(); -} diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index cd60777..64eaccc 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -11,7 +11,6 @@ import { sendNotification } from '$lib/server/push'; import { runBackup } from '$lib/server/backup'; import { publishMusicClip } from '$lib/server/music/publish'; import { deleteWaveform } from '$lib/server/audio/waveform'; -import { getDueQueueEntries, publishQueuedClip } from '$lib/server/queue'; import { createLogger } from '$lib/server/logger'; import { v4 as uuid } from 'uuid'; @@ -22,7 +21,6 @@ let lastReminderDate: string | null = null; const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour const TRIM_CHECK_INTERVAL = 10 * 1000; // 10 seconds -const QUEUE_CHECK_INTERVAL = 30 * 1000; // 30 seconds const REMINDER_HOUR = 9; // 9 AM server time const BACKUP_HOUR = 2; // 2 AM server time const REMINDER_BODIES = [ @@ -41,11 +39,9 @@ export function startScheduler(): void { checkAndSendReminders(); checkAndRunBackup(); checkAndAutoPublish(); - checkAndPublishQueued(); setInterval(checkAndSendReminders, CHECK_INTERVAL); setInterval(checkAndRunBackup, CHECK_INTERVAL); setInterval(checkAndAutoPublish, TRIM_CHECK_INTERVAL); - setInterval(checkAndPublishQueued, QUEUE_CHECK_INTERVAL); log.info('scheduler started'); } @@ -163,21 +159,6 @@ async function sendDailyReminders(): Promise { } } -function checkAndPublishQueued(): void { - try { - const dueEntries = getDueQueueEntries(); - for (const entry of dueEntries) { - try { - publishQueuedClip(entry.id); - } catch (err) { - log.error({ err, clipId: entry.clipId }, 'queue publish failed'); - } - } - } catch (err) { - log.error({ err }, 'queue check failed'); - } -} - async function checkAndAutoPublish(): Promise { try { const now = new Date(); diff --git a/src/lib/server/share-limit.ts b/src/lib/server/share-limit.ts deleted file mode 100644 index d2762fe..0000000 --- a/src/lib/server/share-limit.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { db } from '$lib/server/db'; -import { clips } from '$lib/server/db/schema'; -import { eq, and, gte, sql } from 'drizzle-orm'; -import { checkBurstAvailable } from '$lib/server/queue'; -import { getCloutScore } from '$lib/server/clout'; -import type { TierKey } from '$lib/server/clout'; - -/** - * Calculate the start of "today" in the user's timezone as a UTC Date. - * Falls back to UTC midnight if no timezone is provided or invalid. - */ -export function getTodayStart(tz?: string | null): Date { - const now = new Date(); - if (tz) { - try { - // Format current time in user's timezone to extract local date parts - const formatter = new Intl.DateTimeFormat('en-CA', { - timeZone: tz, - year: 'numeric', - month: '2-digit', - day: '2-digit' - }); - const parts = formatter.formatToParts(now); - const year = parts.find((p) => p.type === 'year')!.value; - const month = parts.find((p) => p.type === 'month')!.value; - const day = parts.find((p) => p.type === 'day')!.value; - - // Build midnight in that timezone, then get the UTC equivalent - // Create a date string and use the timezone to find the offset - const midnightLocal = new Date(`${year}-${month}-${day}T00:00:00`); - const utcRef = new Date(midnightLocal.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzRef = new Date(midnightLocal.toLocaleString('en-US', { timeZone: tz })); - const offsetMs = utcRef.getTime() - tzRef.getTime(); - - return new Date(midnightLocal.getTime() + offsetMs); - } catch { - // Invalid timezone — fall through to UTC - } - } - // Fallback: UTC midnight - const utcMidnight = new Date(now); - utcMidnight.setUTCHours(0, 0, 0, 0); - return utcMidnight; -} - -/** - * Check if a user has hit the daily share limit. - * Returns { allowed, shareCountToday, dailyShareLimit }. - */ -export function checkDailyShareLimit( - userId: string, - groupId: string, - dailyShareLimit: number | null, - tz?: string | null -): { allowed: boolean; shareCountToday: number; dailyShareLimit: number | null } { - if (dailyShareLimit === null) { - return { allowed: true, shareCountToday: 0, dailyShareLimit: null }; - } - - const todayStart = getTodayStart(tz); - - const [result] = db - .select({ count: sql`count(*)` }) - .from(clips) - .where( - and(eq(clips.addedBy, userId), eq(clips.groupId, groupId), gte(clips.createdAt, todayStart)) - ) - .all(); - - const shareCountToday = result?.count ?? 0; - return { - allowed: shareCountToday < dailyShareLimit, - shareCountToday, - dailyShareLimit - }; -} - -export type ShareLimitResult = ReturnType; - -/** - * Human-readable time until midnight reset in the user's timezone. - */ -export function formatResetTime(tz?: string | null): string { - const todayStart = getTodayStart(tz); - const tomorrowStart = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000); - const ms = Math.max(0, tomorrowStart.getTime() - Date.now()); - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`; - if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'}`; - return `${seconds} second${seconds === 1 ? '' : 's'}`; -} - -/** - * If the daily share limit has been reached, return a 429 JSON response. - * Otherwise return null (caller should proceed). - * Also returns the limitCheck data for use in success responses. - */ -export function enforceDailyShareLimit( - userId: string, - groupId: string, - dailyShareLimit: number | null, - tz?: string | null -): { response: Response | null; limitCheck: ShareLimitResult } { - const limitCheck = checkDailyShareLimit(userId, groupId, dailyShareLimit, tz); - if (limitCheck.allowed) return { response: null, limitCheck }; - - const resetsIn = formatResetTime(tz); - return { - response: json( - { - error: `Daily share limit reached (${limitCheck.shareCountToday}/${limitCheck.dailyShareLimit}). Resets in ${resetsIn}.`, - shareCountToday: limitCheck.shareCountToday, - dailyShareLimit: limitCheck.dailyShareLimit, - resetsIn, - limitReached: true - }, - { status: 429 } - ), - limitCheck - }; -} - -/** - * Human-readable relative time string. - */ -export function formatRelativeTime(ms: number): string { - const totalMinutes = Math.ceil(ms / 60_000); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`; - if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'}`; - return `${minutes} minute${minutes === 1 ? '' : 's'}`; -} - -export type SharePacingResult = - | { mode: 'off' } - | { mode: 'daily_cap'; response: Response | null; limitCheck: ShareLimitResult } - | { - mode: 'queue'; - queued: boolean; - scheduledAt?: Date; - nextSlotAt?: Date; - queueFull?: boolean; - clout?: { - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; - tier: string; - tierName: string; - }; - }; - -interface GroupPacingConfig { - sharePacingMode: string; - dailyShareLimit: number | null; - shareBurst: number; - shareCooldownMinutes: number; - cloutEnabled: boolean; -} - -interface UserTierHistory { - cloutTier: string | null; - cloutTierChangedAt: Date | null; -} - -/** - * Unified share pacing check. Dispatches based on group's pacing mode. - * Queue mode never rejects — it returns queued: true with schedule info. - */ -export function checkSharePacing( - userId: string, - groupId: string, - group: GroupPacingConfig, - tz?: string | null, - userTierHistory?: UserTierHistory -): SharePacingResult { - switch (group.sharePacingMode) { - case 'daily_cap': { - const limitCheck = checkDailyShareLimit(userId, groupId, group.dailyShareLimit, tz); - if (limitCheck.allowed) { - return { mode: 'daily_cap', response: null, limitCheck }; - } - const resetsIn = formatResetTime(tz); - return { - mode: 'daily_cap', - response: json( - { - error: `Daily share limit reached (${limitCheck.shareCountToday}/${limitCheck.dailyShareLimit}). Resets in ${resetsIn}.`, - shareCountToday: limitCheck.shareCountToday, - dailyShareLimit: limitCheck.dailyShareLimit, - resetsIn, - limitReached: true - }, - { status: 429 } - ), - limitCheck - }; - } - case 'queue': { - if (group.cloutEnabled) { - const clout = getCloutScore( - userId, - groupId, - group.shareCooldownMinutes, - (userTierHistory?.cloutTier as TierKey) ?? null, - userTierHistory?.cloutTierChangedAt ?? null - ); - const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes); - return { - mode: 'queue', - queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined, - clout: { - cooldownMinutes: clout.cooldownMinutes, - burstSize: clout.burstSize, - queueLimit: clout.queueLimit, - tier: clout.tier, - tierName: clout.tierConfig.name - } - }; - } - // Clout disabled — use base group settings directly - const burst = checkBurstAvailable( - userId, - groupId, - group.shareBurst, - group.shareCooldownMinutes - ); - return { - mode: 'queue', - queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined - }; - } - default: - return { mode: 'off' }; - } -} diff --git a/src/lib/stores/cloutChange.ts b/src/lib/stores/cloutChange.ts deleted file mode 100644 index daed73b..0000000 --- a/src/lib/stores/cloutChange.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { writable } from 'svelte/store'; - -export interface CloutChange { - previousTier: string; - newTier: string; - previousTierName: string; - newTierName: string; - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; -} - -export const cloutChange = writable(null); diff --git a/src/lib/stores/credits.ts b/src/lib/stores/credits.ts new file mode 100644 index 0000000..7f61661 --- /dev/null +++ b/src/lib/stores/credits.ts @@ -0,0 +1,24 @@ +import { writable, derived } from 'svelte/store'; +import { queueCount } from '$lib/stores/queue'; + +/** Current post-credit balance for the logged-in user. */ +export const credits = writable(0); + +/** The group's max credits (cap). Seeded from page.data on mount. */ +export const reciprocityCap = writable(5); + +/** + * The soft feed gate. Active when the user has earned the max credits AND has clips + * waiting in their queue — they must post one to keep scrolling. With an empty queue + * the gate never fires (credits simply stop accruing past the cap), so a user is never + * trapped with nothing to post. + */ +export const gateActive = derived( + [credits, reciprocityCap, queueCount], + ([$credits, $cap, $queue]) => $credits >= $cap && $queue > 0 +); + +/** Update the balance from an API response that reports `credits`. */ +export function setCredits(value: number | undefined): void { + if (typeof value === 'number') credits.set(value); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index eafe676..e982dd3 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -11,12 +11,11 @@ startPolling, stopPolling } from '$lib/stores/notifications'; - import { queueCount } from '$lib/stores/queue'; + import { queueCount, fetchQueueCount } from '$lib/stores/queue'; + import { credits, reciprocityCap } from '$lib/stores/credits'; import { globalMuted } from '$lib/stores/mute'; import { initAudioContext } from '$lib/audio/normalizer'; import { fetchGroupMembers } from '$lib/stores/members'; - import { cloutChange } from '$lib/stores/cloutChange'; - import { addToast } from '$lib/stores/toasts'; import QueueSheet from '$lib/components/QueueSheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -39,54 +38,9 @@ return ''; }); - const TIER_NAMES: Record = { - fresh: 'Fresh', - rising: 'Rising', - viral: 'Viral', - iconic: 'Iconic' - }; - - const TIER_ORDER = ['fresh', 'rising', 'viral', 'iconic']; - - async function checkCloutTier() { - try { - const res = await fetch('/api/clout'); - if (!res.ok) return; - const data = await res.json(); - if (!data.enabled) return; - - if (data.tierChanged && data.lastTier) { - const isRankUp = TIER_ORDER.indexOf(data.tier) > TIER_ORDER.indexOf(data.lastTier); - const changeData = { - previousTier: data.lastTier, - newTier: data.tier, - previousTierName: TIER_NAMES[data.lastTier] ?? data.lastTier, - newTierName: data.tierName, - cooldownMinutes: data.cooldownMinutes, - burstSize: data.burstSize, - queueLimit: data.queueLimit ?? null - }; - - addToast({ - type: 'rank_change', - message: isRankUp ? 'You ranked up!' : 'Your rank changed', - rankIcon: data.icon, - rankTierName: data.tierName, - onTap: () => cloutChange.set(changeData) - }); - - // Acknowledge so it won't show again on other devices - fetch('/api/clout', { method: 'POST' }).catch(() => {}); - } - } catch { - // silently fail - } - } - onMount(() => { startPolling(); fetchGroupMembers(); - checkCloutTier(); // Measure actual bottom nav height and expose as CSS variable. // This adapts to the real safe-area insets on each device instead of @@ -109,6 +63,11 @@ globalMuted.set(user.mutedByDefault ?? true); } + // Seed reciprocity credit state for the feed gate + if (user) credits.set(user.postCredits ?? 0); + if (page.data?.group) reciprocityCap.set(page.data.group.reciprocityCap ?? 5); + fetchQueueCount(); + // Initialize AudioContext on first user interaction (for volume normalization) function handleFirstInteraction() { initAudioContext(); diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 390946b..3c859dd 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -24,6 +24,8 @@ import CatchUpModal from '$lib/components/CatchUpModal.svelte'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { anySheetOpen } from '$lib/stores/sheetOpen'; + import { gateActive } from '$lib/stores/credits'; + import { queueSheetOpen } from '$lib/stores/queueSheet'; import { get } from 'svelte/store'; import { onMount } from 'svelte'; import { page } from '$app/state'; @@ -110,7 +112,6 @@ const lastLegacyShareAt = $derived(page.data.user?.lastLegacyShareAt ?? null); const usedNewShareFlow = $derived(page.data.user?.usedNewShareFlow ?? false); const shortcutUrl = $derived(page.data.group?.shortcutUrl ?? null); - const isQueueMode = $derived(page.data.group?.sharePacingMode === 'queue'); let pushSupported = $state(false); let pushEnabled = $state(false); @@ -196,8 +197,34 @@ } } + // Reciprocity gate: once you've banked the max credits AND have clips waiting in your + // queue, you must post one before scrolling forward to the next clip. Backward scroll is + // always allowed; an empty queue never gates. + let gateShownForEpisode = $state(false); + + function openGate() { + gateShownForEpisode = true; + queueSheetOpen.set(true); + } + + // Re-arm the gate after the user posts (credits drop below cap) or empties the queue. + $effect(() => { + if (!$gateActive) gateShownForEpisode = false; + }); + + // Surface the post sheet the moment the gate engages, so the block is explained rather + // than just feeling stuck. Only auto-opens once per episode and never over another sheet. + $effect(() => { + if ($gateActive && !gateShownForEpisode && !get(anySheetOpen)) openGate(); + }); + function scrollToIndex(index: number) { if (!scrollContainer || index < 0) return; + // Forward advance while gated → open the post sheet instead of moving on. + if (index > activeIndex && get(gateActive)) { + openGate(); + return; + } const slots = scrollContainer.querySelectorAll('.reel-slot'); if (index >= slots.length) return; const slot = slots[index] as HTMLElement | undefined; @@ -915,11 +942,11 @@

All caught up

Drop a clip to kick things off

{:else} -
+
{#each clips as clip, i (clip.id)}
{#if Math.abs(i - activeIndex) <= renderWindow} @@ -1059,6 +1086,13 @@ overscroll-behavior-y: none; scrollbar-width: none; } + /* When the reciprocity gate is engaged, pin the feed so native momentum scrolling can't + slip past the current reel. The post sheet (opened by the gate) is the way forward. */ + .reel-scroll.scroll-locked { + overflow-y: hidden; + scroll-snap-type: none; + touch-action: none; + } .reel-scroll::-webkit-scrollbar { display: none; } diff --git a/src/routes/(app)/me/+page.svelte b/src/routes/(app)/me/+page.svelte index 558eed4..44d95f6 100644 --- a/src/routes/(app)/me/+page.svelte +++ b/src/routes/(app)/me/+page.svelte @@ -17,7 +17,8 @@ import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; import UploadSimpleIcon from 'phosphor-svelte/lib/UploadSimpleIcon'; import CameraIcon from 'phosphor-svelte/lib/CameraIcon'; - import { cloutChange } from '$lib/stores/cloutChange'; + import CreditDots from '$lib/components/CreditDots.svelte'; + import { credits, reciprocityCap } from '$lib/stores/credits'; const user = $derived(page.data.user); const group = $derived(page.data.group); @@ -26,15 +27,6 @@ const gifEnabled = $derived(!!page.data.gifEnabled); let stats = $state<{ uploads: number; saves: number; minutesWatched: number } | null>(null); - let clout = $state<{ - enabled: boolean; - tier?: string; - tierName?: string; - icon?: string; - cooldownMinutes?: number; - burstSize?: number; - queueLimit?: number | null; - } | null>(null); async function loadStats() { try { @@ -45,15 +37,6 @@ } } - async function loadClout() { - try { - const res = await fetch('/api/clout'); - if (res.ok) clout = await res.json(); - } catch { - /* non-critical */ - } - } - function formatWatchTime(minutes: number | null | undefined): string { if (minutes === null || minutes === undefined) return '--'; if (minutes < 1) return '<1m'; @@ -61,19 +44,6 @@ return `${(minutes / 60).toFixed(1)}h`; } - function showCloutModal() { - if (!clout?.enabled || !clout.tier || !clout.tierName) return; - cloutChange.set({ - previousTier: clout.tier, - newTier: clout.tier, - previousTierName: clout.tierName, - newTierName: clout.tierName, - cooldownMinutes: clout.cooldownMinutes ?? 0, - burstSize: clout.burstSize ?? 1, - queueLimit: clout.queueLimit ?? null - }); - } - // Avatar state let avatarCropImage = $state(null); let avatarOverride = $state(undefined); @@ -184,7 +154,6 @@ onMount(() => { loadFaves(); loadStats(); - loadClout(); }); @@ -217,11 +186,9 @@ {/if}
{user?.username} - {#if clout?.enabled && clout.tier && clout.icon} - - {/if} +
+
+
@@ -380,22 +347,10 @@ color: var(--text-primary); letter-spacing: -0.02em; } - .rank-badge { + .credits-row { display: flex; - align-items: center; justify-content: center; - padding: 0; - background: none; - border: none; - cursor: pointer; - } - .rank-badge:active { - transform: scale(0.93); - } - .rank-icon { - width: 22px; - height: 22px; - object-fit: contain; + margin-top: var(--space-sm); } .stats-row { display: flex; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index 7759300..129ef68 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -37,7 +37,7 @@ import DownloadProviderManager from '$lib/components/settings/DownloadProviderManager.svelte'; import PlatformFilter from '$lib/components/settings/PlatformFilter.svelte'; import ShortcutManager from '$lib/components/settings/ShortcutManager.svelte'; - import SharePacingPicker from '$lib/components/settings/SharePacingPicker.svelte'; + import ReciprocityPicker from '$lib/components/settings/ReciprocityPicker.svelte'; import GettingStartedChecklist from '$lib/components/settings/GettingStartedChecklist.svelte'; import SkippedClips from '$lib/components/settings/SkippedClips.svelte'; import Toggle from '$lib/components/settings/Toggle.svelte'; @@ -352,12 +352,11 @@

Share Pacing

-
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 07f72c6..fca128f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,7 +7,6 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import InstallBanner from '$lib/components/InstallBanner.svelte'; import SwUpdateToast from '$lib/components/SwUpdateToast.svelte'; - import CloutChangeModal from '$lib/components/CloutChangeModal.svelte'; import { isStandalone, detectStandaloneMode, @@ -116,7 +115,6 @@ -
diff --git a/src/routes/api/__tests__/clips.test.ts b/src/routes/api/__tests__/clips.test.ts index 63546db..ba9ecdd 100644 --- a/src/routes/api/__tests__/clips.test.ts +++ b/src/routes/api/__tests__/clips.test.ts @@ -35,6 +35,12 @@ const watchedMod = await import('../clips/[id]/watched/+server'); const favoriteMod = await import('../clips/[id]/favorite/+server'); const reactionsMod = await import('../clips/[id]/reactions/+server'); +async function setCreditsFor(userId: string, n: number) { + const { users } = await import('$lib/server/db/schema'); + const { eq } = await import('drizzle-orm'); + db.update(users).set({ postCredits: n }).where(eq(users.id, userId)).run(); +} + // --------------------------------------------------------------------------- // GET /api/clips // --------------------------------------------------------------------------- @@ -306,6 +312,110 @@ describe('POST /api/clips', () => { }); }); +// --------------------------------------------------------------------------- +// POST /api/clips — reciprocity (direct post vs queue) +// --------------------------------------------------------------------------- +describe('POST /api/clips — reciprocity', () => { + async function clearQueueRows(userId: string) { + const { clipQueue } = await import('$lib/server/db/schema'); + const { eq } = await import('drizzle-orm'); + db.delete(clipQueue).where(eq(clipQueue.userId, userId)).run(); + } + async function queueRowExists(clipId: string): Promise { + const { clipQueue } = await import('$lib/server/db/schema'); + const { eq } = await import('drizzle-orm'); + return db.select().from(clipQueue).where(eq(clipQueue.clipId, clipId)).all().length > 0; + } + + it('posts directly (no queue row) when credits available and queue empty', async () => { + await setCreditsFor(data.member.id, 5); + await clearQueueRows(data.member.id); + const event = createMockEvent({ + method: 'POST', + path: '/api/clips', + body: { url: 'https://www.tiktok.com/@x/video/direct-1' }, + user: data.member, + group: data.group + }); + const res = await clipsMod.POST(event as any); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.queued).toBe(false); + expect(await queueRowExists(body.clip.id)).toBe(false); + }); + + it('queues (with a queue row) when out of credits', async () => { + await setCreditsFor(data.member.id, 0); + await clearQueueRows(data.member.id); + const event = createMockEvent({ + method: 'POST', + path: '/api/clips', + body: { url: 'https://www.tiktok.com/@x/video/queued-1' }, + user: data.member, + group: data.group + }); + const res = await clipsMod.POST(event as any); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.queued).toBe(true); + expect(await queueRowExists(body.clip.id)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/clips/[id]/watched — credit earning +// --------------------------------------------------------------------------- +describe('POST /api/clips/[id]/watched — credit earning', () => { + async function freshClip(addedBy: string): Promise { + const { clips } = await import('$lib/server/db/schema'); + const { v4 } = await import('uuid'); + const id = v4(); + db.insert(clips) + .values({ + id, + groupId: data.group.id, + addedBy, + originalUrl: `https://x/${id}`, + platform: 'tiktok', + status: 'ready', + contentType: 'video', + createdAt: new Date() + }) + .run(); + return id; + } + + it("earns a credit watching another member's clip", async () => { + await setCreditsFor(data.member.id, 0); + const clipId = await freshClip(data.host.id); + const event = createMockEvent({ + method: 'POST', + path: `/api/clips/${clipId}/watched`, + params: { id: clipId }, + user: data.member, + group: data.group + }); + const res = await watchedMod.POST(event as any); + const body = await res.json(); + expect(body.credits).toBe(1); + }); + + it('self-watch earns nothing', async () => { + await setCreditsFor(data.host.id, 2); + const clipId = await freshClip(data.host.id); + const event = createMockEvent({ + method: 'POST', + path: `/api/clips/${clipId}/watched`, + params: { id: clipId }, + user: data.host, + group: data.group + }); + const res = await watchedMod.POST(event as any); + const body = await res.json(); + expect(body.credits).toBe(2); + }); +}); + // --------------------------------------------------------------------------- // GET /api/clips/[id] // --------------------------------------------------------------------------- diff --git a/src/routes/api/__tests__/clout.test.ts b/src/routes/api/__tests__/clout.test.ts deleted file mode 100644 index 6e6306b..0000000 --- a/src/routes/api/__tests__/clout.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { seed } from '../../../../tests/helpers/seed'; -import { createMockEvent } from '../../../../tests/helpers/request'; - -vi.mock('$lib/server/push', () => ({ - sendNotification: vi.fn(async () => {}), - notifyNewClip: vi.fn(async () => {}) -})); -vi.mock('$lib/server/scheduler', () => ({ startScheduler: vi.fn() })); -vi.mock('$lib/server/db', async () => { - const { createTestDb } = await import('../../../../tests/helpers/db'); - return createTestDb(); -}); - -const { db } = await import('$lib/server/db'); -const data = await seed(db as any); - -const cloutMod = await import('../clout/+server'); - -describe('GET /api/clout', () => { - it('returns 401 without auth', async () => { - const event = createMockEvent({ method: 'GET', path: '/api/clout' }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(401); - }); - - it('returns enabled:false when not in queue mode', async () => { - const event = createMockEvent({ - method: 'GET', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'off' } - }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.enabled).toBe(false); - }); - - it('returns enabled:true with score+tier when in queue mode + clout enabled', async () => { - const event = createMockEvent({ - method: 'GET', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'queue', cloutEnabled: true } - }); - const res = await cloutMod.GET(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.enabled).toBe(true); - expect(body.tier).toBeDefined(); - expect(body.tierName).toBeDefined(); - }); -}); - -describe('POST /api/clout', () => { - it('returns 401 without auth', async () => { - const event = createMockEvent({ method: 'POST', path: '/api/clout' }); - const res = await cloutMod.POST(event); - expect(res.status).toBe(401); - }); - - it('returns ok:true and updates user tier ack', async () => { - const event = createMockEvent({ - method: 'POST', - path: '/api/clout', - user: data.host, - group: { ...data.group, sharePacingMode: 'queue' } - }); - const res = await cloutMod.POST(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.ok).toBe(true); - }); -}); diff --git a/src/routes/api/__tests__/group-extra.test.ts b/src/routes/api/__tests__/group-extra.test.ts index b928f93..3cffcbc 100644 --- a/src/routes/api/__tests__/group-extra.test.ts +++ b/src/routes/api/__tests__/group-extra.test.ts @@ -15,65 +15,13 @@ vi.mock('$lib/server/db', async () => { const { db } = await import('$lib/server/db'); const data = await seed(db as any); -const dailyMod = await import('../group/daily-share-limit/+server'); const fileSizeMod = await import('../group/max-file-size/+server'); const platformsMod = await import('../group/platforms/+server'); -const pacingMod = await import('../group/share-pacing/+server'); const clipsMod = await import('../group/clips/+server'); const statsMod = await import('../group/stats/+server'); const shortcutMod = await import('../group/shortcut/+server'); const regenerateTokenMod = await import('../group/shortcut/regenerate-token/+server'); -describe('PATCH /api/group/daily-share-limit', () => { - it('rejects non-host', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/daily-share-limit', - body: { dailyShareLimit: 5 }, - user: data.member, - group: data.group - }); - const res = await dailyMod.PATCH(event); - expect([401, 403]).toContain(res.status); - }); - - it('accepts a positive integer', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/daily-share-limit', - body: { dailyShareLimit: 5 }, - user: data.host, - group: data.group - }); - const res = await dailyMod.PATCH(event); - expect(res.status).toBe(200); - }); - - it('accepts null (no limit)', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/daily-share-limit', - body: { dailyShareLimit: null }, - user: data.host, - group: data.group - }); - const res = await dailyMod.PATCH(event); - expect(res.status).toBe(200); - }); - - it('rejects 0 or negative', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/daily-share-limit', - body: { dailyShareLimit: 0 }, - user: data.host, - group: data.group - }); - const res = await dailyMod.PATCH(event); - expect(res.status).toBe(400); - }); -}); - describe('PATCH /api/group/max-file-size', () => { it('rejects invalid size', async () => { const event = createMockEvent({ @@ -152,63 +100,6 @@ describe('PATCH /api/group/platforms', () => { }); }); -describe('PATCH /api/group/share-pacing', () => { - it('rejects invalid mode', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/share-pacing', - body: { sharePacingMode: 'wrong' }, - user: data.host, - group: data.group - }); - const res = await pacingMod.PATCH(event); - expect(res.status).toBe(400); - }); - - it('rejects burst out of range', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/share-pacing', - body: { shareBurst: 99 }, - user: data.host, - group: data.group - }); - const res = await pacingMod.PATCH(event); - expect(res.status).toBe(400); - }); - - it('rejects cooldown not in valid set', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/share-pacing', - body: { shareCooldownMinutes: 45 }, - user: data.host, - group: data.group - }); - const res = await pacingMod.PATCH(event); - expect(res.status).toBe(400); - }); - - it('accepts valid combo', async () => { - const event = createMockEvent({ - method: 'PATCH', - path: '/api/group/share-pacing', - body: { - sharePacingMode: 'queue', - shareBurst: 2, - shareCooldownMinutes: 60, - cloutEnabled: true - }, - user: data.host, - group: data.group - }); - const res = await pacingMod.PATCH(event); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.sharePacingMode).toBe('queue'); - }); -}); - describe('GET /api/group/clips', () => { it('rejects non-host', async () => { const event = createMockEvent({ diff --git a/src/routes/api/__tests__/group-reciprocity.test.ts b/src/routes/api/__tests__/group-reciprocity.test.ts new file mode 100644 index 0000000..5842b7a --- /dev/null +++ b/src/routes/api/__tests__/group-reciprocity.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { seed } from '../../../../tests/helpers/seed'; +import { createMockEvent } from '../../../../tests/helpers/request'; +import * as schema from '../../../../src/lib/server/db/schema'; + +vi.mock('$lib/server/push', () => ({ + sendNotification: vi.fn(async () => {}), + notifyNewClip: vi.fn(async () => {}) +})); +vi.mock('$lib/server/scheduler', () => ({ startScheduler: vi.fn() })); +vi.mock('$lib/server/db', async () => { + const { createTestDb } = await import('../../../../tests/helpers/db'); + return createTestDb(); +}); + +const { db } = await import('$lib/server/db'); +const data = await seed(db as any); +const mod = await import('../group/reciprocity/+server'); + +function patch(body: Record, user = data.host) { + return mod.PATCH( + createMockEvent({ + method: 'PATCH', + path: '/api/group/reciprocity', + body, + user, + group: data.group + }) + ); +} + +describe('PATCH /api/group/reciprocity', () => { + it('rejects non-host', async () => { + const res = await patch({ cap: 8 }, data.member); + expect([401, 403]).toContain(res.status); + }); + + it('accepts a valid config', async () => { + const res = await patch({ seed: 3, cap: 8, ratio: 2 }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ seed: 3, cap: 8, ratio: 2, dailyPostLimit: null }); + }); + + it('sets and clears the daily post limit', async () => { + const set = await patch({ dailyPostLimit: 10 }); + expect(set.status).toBe(200); + expect((await set.json()).dailyPostLimit).toBe(10); + const clear = await patch({ dailyPostLimit: null }); + expect((await clear.json()).dailyPostLimit).toBeNull(); + }); + + it('rejects an out-of-range daily post limit', async () => { + const res = await patch({ dailyPostLimit: 0 }); + expect(res.status).toBe(400); + }); + + it('rejects seed greater than cap', async () => { + const res = await patch({ seed: 10, cap: 5 }); + expect(res.status).toBe(400); + }); + + it('rejects out-of-range cap', async () => { + const res = await patch({ cap: 999 }); + expect(res.status).toBe(400); + }); + + it('rejects non-integer ratio', async () => { + const res = await patch({ ratio: 1.5 }); + expect(res.status).toBe(400); + }); + + it('lowering the cap clamps members above it', async () => { + // Give the member a high balance, then lower the cap below it. + (db as any) + .update(schema.users) + .set({ postCredits: 9 }) + .where(eq(schema.users.id, data.member.id)) + .run(); + const res = await patch({ seed: 3, cap: 5, ratio: 1 }); + expect(res.status).toBe(200); + const [m] = (db as any) + .select({ c: schema.users.postCredits }) + .from(schema.users) + .where(eq(schema.users.id, data.member.id)) + .all(); + expect(m.c).toBe(5); + }); +}); diff --git a/src/routes/api/__tests__/queue.test.ts b/src/routes/api/__tests__/queue.test.ts index 7372b79..645665b 100644 --- a/src/routes/api/__tests__/queue.test.ts +++ b/src/routes/api/__tests__/queue.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { v4 as uuid } from 'uuid'; +import { eq } from 'drizzle-orm'; import { seed } from '../../../../tests/helpers/seed'; import { createMockEvent } from '../../../../tests/helpers/request'; import * as schema from '../../../../src/lib/server/db/schema'; @@ -20,10 +21,9 @@ const data = await seed(db as any); const queueMod = await import('../queue/+server'); const queueCountMod = await import('../queue/count/+server'); const queueIdMod = await import('../queue/[id]/+server'); -const queueMoveTopMod = await import('../queue/[id]/move-to-top/+server'); -const queueReorderMod = await import('../queue/reorder/+server'); +const queuePostMod = await import('../queue/post/+server'); -function enqueue() { +function enqueue(userId = data.host.id) { const cId = uuid(); const qId = uuid(); const now = new Date(); @@ -32,7 +32,7 @@ function enqueue() { .values({ id: cId, groupId: data.group.id, - addedBy: data.host.id, + addedBy: userId, originalUrl: `https://x/${cId}`, platform: 'tiktok', status: 'queued', @@ -42,21 +42,18 @@ function enqueue() { .run(); (db as any) .insert(schema.clipQueue) - .values({ - id: qId, - clipId: cId, - userId: data.host.id, - groupId: data.group.id, - position: 0, - scheduledAt: new Date(Date.now() + 60_000), - createdAt: now - }) + .values({ id: qId, clipId: cId, userId, groupId: data.group.id, createdAt: now }) .run(); return { qId, cId }; } +function setCredits(userId: string, n: number) { + (db as any).update(schema.users).set({ postCredits: n }).where(eq(schema.users.id, userId)).run(); +} + beforeEach(() => { (db as any).delete(schema.clipQueue).run(); + setCredits(data.host.id, 5); }); describe('GET /api/queue', () => { @@ -78,7 +75,7 @@ describe('GET /api/queue', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.queue).toHaveLength(1); - expect(body.queue[0].position).toBe(0); + expect(body.total).toBe(1); }); }); @@ -143,46 +140,72 @@ describe('DELETE /api/queue/[id]', () => { }); }); -describe('POST /api/queue/[id]/move-to-top', () => { - it('moves entry to position 0', async () => { - enqueue(); - const { qId } = enqueue(); +describe('POST /api/queue/post', () => { + it('returns 401 without auth', async () => { + const event = createMockEvent({ method: 'POST', path: '/api/queue/post', body: { ids: [] } }); + const res = await queuePostMod.POST(event); + expect(res.status).toBe(401); + }); + + it('400 on empty ids', async () => { const event = createMockEvent({ method: 'POST', - path: `/api/queue/${qId}/move-to-top`, - params: { id: qId }, + path: '/api/queue/post', + body: { ids: [] }, user: data.host, group: data.group }); - const res = await queueMoveTopMod.POST(event); - expect([200, 204]).toContain(res.status); + const res = await queuePostMod.POST(event); + expect(res.status).toBe(400); }); -}); -describe('PATCH /api/queue/reorder', () => { - it('reorders entries by orderedIds', async () => { - const e1 = enqueue(); - const e2 = enqueue(); + it('posts selected clips and spends credits', async () => { + const a = enqueue(); + const b = enqueue(); + setCredits(data.host.id, 5); const event = createMockEvent({ - method: 'PATCH', - path: '/api/queue/reorder', - body: { orderedIds: [e2.qId, e1.qId] }, + method: 'POST', + path: '/api/queue/post', + body: { ids: [a.qId, b.qId] }, user: data.host, group: data.group }); - const res = await queueReorderMod.PATCH(event); - expect([200, 204]).toContain(res.status); + const res = await queuePostMod.POST(event); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.posted).toHaveLength(2); + expect(body.credits).toBe(3); + expect(body.queueLength).toBe(0); }); - it('returns 400 on missing orderedIds', async () => { + it('self-limits to available credits', async () => { + const a = enqueue(); + const b = enqueue(); + setCredits(data.host.id, 1); const event = createMockEvent({ - method: 'PATCH', - path: '/api/queue/reorder', - body: {}, + method: 'POST', + path: '/api/queue/post', + body: { ids: [a.qId, b.qId] }, user: data.host, group: data.group }); - const res = await queueReorderMod.PATCH(event); - expect([400, 422]).toContain(res.status); + const res = await queuePostMod.POST(event); + const body = await res.json(); + expect(body.posted).toHaveLength(1); + expect(body.credits).toBe(0); + }); + + it("won't post another user's queue entry", async () => { + const { qId } = enqueue(data.host.id); + const event = createMockEvent({ + method: 'POST', + path: '/api/queue/post', + body: { ids: [qId] }, + user: data.member, + group: data.group + }); + const res = await queuePostMod.POST(event); + const body = await res.json(); + expect(body.posted).toHaveLength(0); }); }); diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index a3665a4..fb2f54b 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -11,6 +11,7 @@ import { users, groups, notificationPreferences, verificationCodes } from '$lib/ import { v4 as uuid } from 'uuid'; import { eq, and, desc, ne, isNull } from 'drizzle-orm'; import { sendVerification, checkVerification } from '$lib/server/sms/verify'; +import { getReciprocityConfig } from '$lib/server/credits'; import { dev } from '$app/environment'; import { createLogger } from '$lib/server/logger'; import { checkRateLimit, rateLimitResponse } from '$lib/server/rate-limit'; @@ -33,6 +34,7 @@ async function handleJoin(body: Record) { username: '', phone: placeholderPhone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: new Date() }); @@ -186,6 +188,7 @@ async function ensureDevUser(phone: string, existingUser: typeof users.$inferSel name: 'Dev Group', inviteCode: 'dev', shortcutToken: uuid(), + reciprocityStartedAt: new Date(), createdAt: new Date() }); group = await db.query.groups.findFirst({ where: eq(groups.id, groupId) }); @@ -202,6 +205,7 @@ async function ensureDevUser(phone: string, existingUser: typeof users.$inferSel username: 'dev', phone, groupId: group!.id, + postCredits: getReciprocityConfig(group!.id).seed, createdAt: new Date() }); await db.insert(notificationPreferences).values({ userId }); diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 6c2eec5..711846f 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -33,8 +33,8 @@ import { safeInt } from '$lib/server/api-utils'; import { extractMentions, notifyMentions } from '$lib/server/mentions'; -import { checkSharePacing, formatRelativeTime } from '$lib/server/share-limit'; -import { enqueueClip } from '$lib/server/queue'; +import { enqueueClip, getQueueCount } from '$lib/server/queue'; +import { getCredits, dailyPostsRemaining } from '$lib/server/credits'; import { createLogger } from '$lib/server/logger'; const log = createLogger('clips'); @@ -381,61 +381,6 @@ async function autoPostComment( } } -type QueueResult = { queued: false } | { queued: true; scheduledAt: Date; position: number }; - -function tryEnqueue( - pacing: import('$lib/server/share-limit').SharePacingResult, - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst: number -): QueueResult | Response { - if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - // Use clout-adjusted values when available - const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; - const effectiveBurst = pacing.clout?.burstSize ?? burst; - const queueLimit = pacing.clout?.queueLimit ?? null; - const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); - if (!entry) { - const tierMsg = pacing.clout ? ` You're at ${pacing.clout.tierName} tier.` : ''; - const limit = pacing.clout?.queueLimit ?? 10; - return json( - { - error: `Queue full (${limit}/${limit}).${tierMsg} Share clips that get reactions to unlock more capacity.`, - queueFull: true, - tier: pacing.clout?.tier - }, - { status: 429 } - ); - } - return { queued: true, scheduledAt: entry.scheduledAt, position: entry.position }; -} - -function buildClipResponse( - clipId: string, - contentType: string, - pacing: import('$lib/server/share-limit').SharePacingResult, - qr: QueueResult -): Record { - const resp: Record = { - clip: { id: clipId, status: 'downloading', contentType } - }; - if (qr.queued) - Object.assign(resp, { - queued: true, - scheduledAt: qr.scheduledAt.toISOString(), - queuePosition: qr.position, - sharesIn: formatRelativeTime(Math.max(0, qr.scheduledAt.getTime() - Date.now())) - }); - if (pacing.mode === 'daily_cap') - Object.assign(resp, { - shareCountToday: pacing.limitCheck.shareCountToday + 1, - dailyShareLimit: pacing.limitCheck.dailyShareLimit - }); - return resp; -} - async function handleExistingClip( existing: typeof clips.$inferSelect, validUrl: string, @@ -450,19 +395,15 @@ async function handleExistingClip( ); } const qe = db - .select({ scheduledAt: clipQueue.scheduledAt }) + .select({ id: clipQueue.id }) .from(clipQueue) .where(eq(clipQueue.clipId, existing.id)) .get(); const resp: Record = { - error: 'This link has already been added to the feed.', + error: 'This link has already been added.', addedBy: existing.addedBy }; - if (qe) - Object.assign(resp, { - inQueue: true, - sharesIn: formatRelativeTime(Math.max(0, qe.scheduledAt.getTime() - Date.now())) - }); + if (qe) Object.assign(resp, { inQueue: true }); return json(resp, { status: 409 }); } @@ -489,18 +430,21 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } const contentType = getContentType(platform); const normalizedUrl = normalizeUrl(validUrl); - const tz = typeof body.tz === 'string' ? body.tz : null; - const pacing = checkSharePacing(user.id, user.groupId, group, tz, { - cloutTier: user.cloutTier, - cloutTierChangedAt: user.cloutTierChangedAt - }); - if (pacing.mode === 'daily_cap' && pacing.response) return pacing.response; - const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, user.groupId), eq(clips.originalUrl, normalizedUrl)) }); if (existing) return handleExistingClip(existing, validUrl, title); + // Reciprocity decision: a clip posts straight to the feed only if you have a credit to + // spend AND nothing is already waiting in your queue. Otherwise it's held in the queue + // to be posted later (by spending a credit via the post gate). The credit itself is + // spent at PUBLISH time inside setClipReady — so a failed download never costs anything. + const hasCredit = getCredits(user.id) >= 1; + const queueEmpty = getQueueCount(user.id, user.groupId) === 0; + const dailyRemaining = dailyPostsRemaining(user.id, user.groupId); + const underDailyCap = dailyRemaining === null || dailyRemaining > 0; + const directPost = hasCredit && queueEmpty && underDailyCap; + const clipId = uuid(); const now = new Date(); try { @@ -527,17 +471,20 @@ export const POST: RequestHandler = withAuth(async ({ request }, { user, group } return json({ error: 'Failed to create clip' }, { status: 500 }); } - const queueResult = tryEnqueue( - pacing, - clipId, - user.id, - user.groupId, - group.shareCooldownMinutes, - group.shareBurst - ); - if (queueResult instanceof Response) return queueResult; + // A queue row marks the clip as "held"; its presence is how setClipReady tells a queued + // clip from a direct post when the download finishes. + if (!directPost) enqueueClip(clipId, user.id, user.groupId); await startDownload(clipId, validUrl, contentType, 'new clip'); if (message) await autoPostComment(clipId, user, message, now); - return json(buildClipResponse(clipId, contentType, pacing, queueResult), { status: 201 }); + + return json( + { + clip: { id: clipId, status: 'downloading', contentType }, + queued: !directPost, + credits: getCredits(user.id), + queueLength: getQueueCount(user.id, user.groupId) + }, + { status: 201 } + ); }); diff --git a/src/routes/api/clips/[id]/watched/+server.ts b/src/routes/api/clips/[id]/watched/+server.ts index 0101e2c..e4f9906 100644 --- a/src/routes/api/clips/[id]/watched/+server.ts +++ b/src/routes/api/clips/[id]/watched/+server.ts @@ -4,8 +4,9 @@ import { db } from '$lib/server/db'; import { watched } from '$lib/server/db/schema'; import { and, eq, sql } from 'drizzle-orm'; import { withClipAuth } from '$lib/server/api-utils'; +import { getCredits, grantWatchCredit } from '$lib/server/credits'; -export const POST: RequestHandler = withClipAuth(async ({ params, request }, { user }) => { +export const POST: RequestHandler = withClipAuth(async ({ params, request }, { user, clip }) => { // Parse optional watchPercent from body let watchPercent: number | null = null; try { @@ -17,6 +18,18 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u // No body or invalid JSON — backward compatible with no-body calls } + // A watch earns a reciprocity credit only the FIRST time you watch SOMEONE ELSE'S clip. + // Detect "new watch" before the upsert; self-watches (your own uploads, auto-marked at + // 100%) never earn. See $lib/server/credits. + const isSelf = clip.addedBy === user.id; + const alreadyWatched = isSelf + ? true + : !!db + .select({ clipId: watched.clipId }) + .from(watched) + .where(and(eq(watched.clipId, params.id), eq(watched.userId, user.id))) + .get(); + await db .insert(watched) .values({ @@ -36,7 +49,11 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u } }); - return json({ watched: true }); + if (!isSelf && !alreadyWatched) { + grantWatchCredit(user.id, clip.groupId, clip.createdAt); + } + + return json({ watched: true, credits: getCredits(user.id) }); }); // PATCH: update watchPercent only — creates the row if needed but does NOT count as "watched" diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index 3707dde..c8e21b3 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -16,8 +16,8 @@ import { startDownload } from '$lib/server/clip-download'; import { getActiveProvider } from '$lib/server/providers/registry'; import { createLogger } from '$lib/server/logger'; import { authenticateShortcutToken } from '$lib/server/shortcut-auth'; -import { checkSharePacing, formatRelativeTime } from '$lib/server/share-limit'; -import { enqueueClip } from '$lib/server/queue'; +import { enqueueClip, getQueueCount } from '$lib/server/queue'; +import { getCredits, dailyPostsRemaining } from '$lib/server/credits'; const log = createLogger('share'); @@ -102,33 +102,6 @@ async function validateShareRequest( return { matchedUser, group, platform, videoUrl }; } -/** Try to enqueue a clip. Returns queue info or an error response if queue is full. */ -function tryEnqueueShare( - pacing: import('$lib/server/share-limit').SharePacingResult, - clipId: string, - userId: string, - groupId: string, - cooldownMinutes: number, - burst: number -): { queued: false } | { queued: true; sharesIn: string } | Response { - if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; - const effectiveBurst = pacing.clout?.burstSize ?? burst; - const queueLimit = pacing.clout?.queueLimit ?? null; - const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); - if (!entry) { - const limit = pacing.clout?.queueLimit ?? 10; - const tierMsg = pacing.clout ? ` (${pacing.clout.tierName} tier)` : ''; - return shareResponse(false, `❌ Queue full${tierMsg} (${limit}/${limit}).`, 429, { - queueFull: true - }); - } - return { - queued: true, - sharesIn: formatRelativeTime(Math.max(0, entry.scheduledAt.getTime() - Date.now())) - }; -} - export const POST: RequestHandler = async ({ request, url, locals }) => { const body = await parseShareBody(request); if (body instanceof Response) return body; @@ -142,18 +115,6 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { const contentType = getContentType(platform); const normalizedVideoUrl = normalizeUrl(videoUrl); - // 6.5. Check share pacing (off / daily_cap / queue) - const tz = typeof body.tz === 'string' ? body.tz : null; - const pacing = checkSharePacing(matchedUser.id, group.id, group, tz); - if (pacing.mode === 'daily_cap' && pacing.response) { - return shareResponse( - false, - `❌ Daily limit reached (${pacing.limitCheck.shareCountToday}/${pacing.limitCheck.dailyShareLimit}). Resets soon.`, - 429, - { limitReached: true } - ); - } - // 7. Duplicate check const existing = await db.query.clips.findFirst({ where: and(eq(clips.groupId, group.id), eq(clips.originalUrl, normalizedVideoUrl)) @@ -169,6 +130,14 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, '❌ This clip has already been shared!', 409); } + // Reciprocity: post straight to the feed only with a credit in hand, an empty queue, and + // under the daily cap; otherwise hold it in the queue. Credit spent at publish (setClipReady). + const dailyRemaining = dailyPostsRemaining(matchedUser.id, group.id); + const directPost = + getCredits(matchedUser.id) >= 1 && + getQueueCount(matchedUser.id, group.id) === 0 && + (dailyRemaining === null || dailyRemaining > 0); + // 8. Create clip + auto-watched in a transaction so both succeed or fail together const clipId = uuid(); const now = new Date(); @@ -203,16 +172,8 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { return shareResponse(false, 'Something went wrong. Try sharing again.', 500); } - // Enqueue if burst exhausted in queue mode - const queueInfo = tryEnqueueShare( - pacing, - clipId, - matchedUser.id, - group.id, - group.shareCooldownMinutes, - group.shareBurst - ); - if (queueInfo instanceof Response) return queueInfo; + // A queue row marks the clip as held; its absence means direct-post (spent at publish). + if (!directPost) enqueueClip(clipId, matchedUser.id, group.id); // Async download (skip trim for shortcut shares — no UI available) await startDownload(clipId, videoUrl, contentType, 'new clip', { skipTrim: true }); @@ -220,18 +181,14 @@ export const POST: RequestHandler = async ({ request, url, locals }) => { // Record legacy share timestamp for upgrade banner db.update(users).set({ lastLegacyShareAt: now }).where(eq(users.id, matchedUser.id)).run(); - const message = queueInfo.queued - ? `✅ Clip queued! Shares in ~${queueInfo.sharesIn}.` - : '✅ Clip shared!'; + const message = directPost + ? '✅ Clip shared!' + : '✅ Saved to your queue — post it from Scrolly when you’re ready.'; return shareResponse(true, message, 201, { clipId, - ...(queueInfo.queued ? { queued: true, sharesIn: queueInfo.sharesIn } : {}), - ...(pacing.mode === 'daily_cap' - ? { - shareCountToday: pacing.limitCheck.shareCountToday + 1, - dailyShareLimit: pacing.limitCheck.dailyShareLimit - } - : {}) + queued: !directPost, + credits: getCredits(matchedUser.id), + queueLength: getQueueCount(matchedUser.id, group.id) }); }; diff --git a/src/routes/api/clout/+server.ts b/src/routes/api/clout/+server.ts deleted file mode 100644 index 30824a4..0000000 --- a/src/routes/api/clout/+server.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth } from '$lib/server/api-utils'; -import { getCloutScore, getNextTier, TIERS } from '$lib/server/clout'; -import type { TierKey } from '$lib/server/clout'; -import { db } from '$lib/server/db'; -import { users } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { dev } from '$app/environment'; - -export const GET: RequestHandler = withAuth(async (event, { user, group }) => { - if (group.sharePacingMode !== 'queue' || !group.cloutEnabled) { - return json({ enabled: false }); - } - - const result = getCloutScore( - user.id, - user.groupId, - group.shareCooldownMinutes, - (user.cloutTier as TierKey) ?? null, - user.cloutTierChangedAt ?? null - ); - - // DEV ONLY: ?tier=rising to force a specific tier for testing - if (dev) { - const forceTier = event.url.searchParams.get('tier'); - if (forceTier && forceTier in TIERS) { - const config = TIERS[forceTier as keyof typeof TIERS]; - result.tier = forceTier as keyof typeof TIERS; - result.tierConfig = config; - result.cooldownMinutes = Math.round(group.shareCooldownMinutes * config.cooldownMultiplier); - result.burstSize = config.burst; - result.queueLimit = config.queueLimit; - } - } - - const nextTier = getNextTier(result.tier); - - // Determine if a tier change should be reported - const lastAckedTier = user.cloutTier; - let tierChanged = false; - - if (!lastAckedTier) { - // First time — seed the tier silently (no notification on first load) - db.update(users) - .set({ cloutTier: result.tier, cloutTierChangedAt: new Date() }) - .where(eq(users.id, user.id)) - .run(); - } else if (result.tierActuallyChanged) { - tierChanged = true; - // Persist the new effective tier and reset the changed-at timestamp - db.update(users) - .set({ cloutTier: result.tier, cloutTierChangedAt: new Date() }) - .where(eq(users.id, user.id)) - .run(); - } - - return json({ - enabled: true, - score: result.score, - tier: result.tier, - tierName: result.tierConfig.name, - cooldownMinutes: result.cooldownMinutes, - burstSize: result.burstSize, - queueLimit: result.queueLimit, - icon: result.tierConfig.icon, - breakdown: result.breakdown.map((b) => ({ clipId: b.clipId, score: b.score })), - nextTier: nextTier - ? { - tier: nextTier.key, - tierName: nextTier.config.name, - minScore: nextTier.config.minScore, - cooldownMultiplier: nextTier.config.cooldownMultiplier, - burst: nextTier.config.burst, - queueLimit: nextTier.config.queueLimit, - icon: nextTier.config.icon - } - : null, - baseCooldownMinutes: group.shareCooldownMinutes, - lastTier: lastAckedTier ?? null, - tierChanged - }); -}); - -/** Acknowledge that the tier change was seen. Updates the acked tier so - * future checks compare against the tier the user was notified about. */ -export const POST: RequestHandler = withAuth(async (_event, { user, group }) => { - // Recompute current tier so we store the accurate value - const result = getCloutScore( - user.id, - user.groupId, - group.shareCooldownMinutes, - (user.cloutTier as TierKey) ?? null, - user.cloutTierChangedAt ?? null - ); - db.update(users) - .set({ - cloutTier: result.tier, - cloutChangeShownAt: new Date(), - cloutTierChangedAt: new Date() - }) - .where(eq(users.id, user.id)) - .run(); - return json({ ok: true }); -}); diff --git a/src/routes/api/group/daily-share-limit/+server.ts b/src/routes/api/group/daily-share-limit/+server.ts deleted file mode 100644 index b866665..0000000 --- a/src/routes/api/group/daily-share-limit/+server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$lib/server/db'; -import { groups } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { withHost, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; - -export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { - const body = await parseBody<{ dailyShareLimit?: number | null }>(request); - if (isResponse(body)) return body; - - const { dailyShareLimit } = body; - - if (dailyShareLimit !== null && dailyShareLimit !== undefined) { - if (!Number.isInteger(dailyShareLimit) || dailyShareLimit < 1) { - return badRequest('Daily share limit must be a positive integer or null'); - } - } - - await db - .update(groups) - .set({ dailyShareLimit: dailyShareLimit ?? null }) - .where(eq(groups.id, group.id)); - - return json({ dailyShareLimit }); -}); diff --git a/src/routes/api/group/members/+server.ts b/src/routes/api/group/members/+server.ts index 8cd0843..0ebb684 100644 --- a/src/routes/api/group/members/+server.ts +++ b/src/routes/api/group/members/+server.ts @@ -6,6 +6,7 @@ import { eq, isNull, and } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { normalizePhone } from '$lib/server/phone'; import { withAuth, withHost, parseBody, isResponse } from '$lib/server/api-utils'; +import { getReciprocityConfig } from '$lib/server/credits'; export const GET: RequestHandler = withAuth(async (_event, { group }) => { const members = await db.query.users.findMany({ @@ -70,6 +71,7 @@ export const POST: RequestHandler = withHost(async ({ request }, { group }) => { username, phone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: now }) .run(); diff --git a/src/routes/api/group/reciprocity/+server.ts b/src/routes/api/group/reciprocity/+server.ts new file mode 100644 index 0000000..14e0847 --- /dev/null +++ b/src/routes/api/group/reciprocity/+server.ts @@ -0,0 +1,74 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { groups, users } from '$lib/server/db/schema'; +import { eq, sql } from 'drizzle-orm'; +import { withHost, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; + +const CAP_MIN = 1; +const CAP_MAX = 20; +const RATIO_MIN = 1; +const RATIO_MAX = 10; +const DAILY_MAX = 100; + +function asInt(v: unknown, fallback: number): number | null { + if (v === undefined) return fallback; + if (typeof v !== 'number' || !Number.isInteger(v)) return null; + return v; +} + +export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { + const body = await parseBody<{ + seed?: number; + cap?: number; + ratio?: number; + dailyPostLimit?: number | null; + }>(request); + if (isResponse(body)) return body; + + const seed = asInt(body.seed, group.reciprocitySeed); + const cap = asInt(body.cap, group.reciprocityCap); + const ratio = asInt(body.ratio, group.reciprocityRatio); + + // dailyPostLimit is nullable: undefined keeps current, null clears it, a number sets it. + let dailyPostLimit: number | null; + if (body.dailyPostLimit === undefined) { + dailyPostLimit = group.dailyPostLimit; + } else if (body.dailyPostLimit === null) { + dailyPostLimit = null; + } else if (Number.isInteger(body.dailyPostLimit) && body.dailyPostLimit >= 1) { + dailyPostLimit = body.dailyPostLimit; + } else { + return badRequest(`dailyPostLimit must be null or an integer ≥ 1`); + } + if (dailyPostLimit !== null && dailyPostLimit > DAILY_MAX) { + return badRequest(`dailyPostLimit must be ≤ ${DAILY_MAX}`); + } + + if (seed === null || cap === null || ratio === null) { + return badRequest('seed, cap, and ratio must be integers'); + } + if (cap < CAP_MIN || cap > CAP_MAX) { + return badRequest(`cap must be between ${CAP_MIN} and ${CAP_MAX}`); + } + if (seed < 0 || seed > cap) { + return badRequest('seed must be between 0 and cap'); + } + if (ratio < RATIO_MIN || ratio > RATIO_MAX) { + return badRequest(`ratio must be between ${RATIO_MIN} and ${RATIO_MAX}`); + } + + db.transaction((tx) => { + tx.update(groups) + .set({ reciprocitySeed: seed, reciprocityCap: cap, reciprocityRatio: ratio, dailyPostLimit }) + .where(eq(groups.id, group.id)) + .run(); + // Lowering the cap shouldn't strand members above it — clamp existing balances. + tx.update(users) + .set({ postCredits: sql`MIN(${users.postCredits}, ${cap})` }) + .where(eq(users.groupId, group.id)) + .run(); + }); + + return json({ seed, cap, ratio, dailyPostLimit }); +}); diff --git a/src/routes/api/group/share-pacing/+server.ts b/src/routes/api/group/share-pacing/+server.ts deleted file mode 100644 index 5b3e005..0000000 --- a/src/routes/api/group/share-pacing/+server.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { db } from '$lib/server/db'; -import { groups } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; -import { withHost, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; -import { flushQueue } from '$lib/server/queue'; - -const VALID_MODES = ['off', 'daily_cap', 'queue'] as const; -const VALID_COOLDOWNS = [30, 60, 120, 240, 360] as const; - -interface PacingBody { - sharePacingMode?: string; - shareBurst?: number; - shareCooldownMinutes?: number; - dailyShareLimit?: number | null; - cloutEnabled?: boolean; -} - -function validatePacingBody(body: PacingBody): string | null { - const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit } = body; - if ( - sharePacingMode !== undefined && - !(VALID_MODES as readonly string[]).includes(sharePacingMode) - ) { - return 'Invalid pacing mode. Must be off, daily_cap, or queue.'; - } - if ( - shareBurst !== undefined && - (!Number.isInteger(shareBurst) || shareBurst < 1 || shareBurst > 10) - ) { - return 'Burst must be an integer between 1 and 10.'; - } - if ( - shareCooldownMinutes !== undefined && - !(VALID_COOLDOWNS as readonly number[]).includes(shareCooldownMinutes) - ) { - return 'Cooldown must be 30, 60, 120, 240, or 360 minutes.'; - } - if ( - dailyShareLimit !== undefined && - dailyShareLimit !== null && - (!Number.isInteger(dailyShareLimit) || dailyShareLimit < 1) - ) { - return 'Daily share limit must be a positive integer or null.'; - } - return null; -} - -export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => { - const body = await parseBody(request); - if (isResponse(body)) return body; - - const error = validatePacingBody(body); - if (error) return badRequest(error); - - const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit, cloutEnabled } = body; - - const updates: Record = {}; - if (sharePacingMode !== undefined) updates.sharePacingMode = sharePacingMode; - if (shareBurst !== undefined) updates.shareBurst = shareBurst; - if (shareCooldownMinutes !== undefined) updates.shareCooldownMinutes = shareCooldownMinutes; - if (dailyShareLimit !== undefined) updates.dailyShareLimit = dailyShareLimit ?? null; - if (cloutEnabled !== undefined) updates.cloutEnabled = cloutEnabled; - - if (Object.keys(updates).length === 0) return badRequest('No fields to update.'); - - // If switching away from queue mode, flush all queued clips - if (group.sharePacingMode === 'queue' && (sharePacingMode ?? 'queue') !== 'queue') { - await flushQueue(group.id); - } - - db.update(groups).set(updates).where(eq(groups.id, group.id)).run(); - - return json({ - sharePacingMode: (updates.sharePacingMode as string) ?? group.sharePacingMode, - shareBurst: (updates.shareBurst as number) ?? group.shareBurst, - shareCooldownMinutes: (updates.shareCooldownMinutes as number) ?? group.shareCooldownMinutes, - dailyShareLimit: - dailyShareLimit !== undefined ? (dailyShareLimit ?? null) : group.dailyShareLimit, - cloutEnabled: cloutEnabled !== undefined ? cloutEnabled : group.cloutEnabled - }); -}); diff --git a/src/routes/api/queue/+server.ts b/src/routes/api/queue/+server.ts index 749d158..38f6d21 100644 --- a/src/routes/api/queue/+server.ts +++ b/src/routes/api/queue/+server.ts @@ -1,19 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { withAuth } from '$lib/server/api-utils'; -import { getUserQueue, clearQueue } from '$lib/server/queue'; -import { formatRelativeTime } from '$lib/server/share-limit'; +import { withAuth, safeInt } from '$lib/server/api-utils'; +import { getUserQueue, clearQueue, getQueueCount } from '$lib/server/queue'; -export const GET: RequestHandler = withAuth(async (_event, { user }) => { - const entries = getUserQueue(user.id, user.groupId); +export const GET: RequestHandler = withAuth(async ({ url }, { user }) => { + const limit = safeInt(url.searchParams.get('limit'), 20, 50); + const offset = safeInt(url.searchParams.get('offset'), 0); + const order = url.searchParams.get('order') === 'oldest' ? 'oldest' : 'newest'; + + const entries = getUserQueue(user.id, user.groupId, { limit, offset, order }); + const total = getQueueCount(user.id, user.groupId); - const now = Date.now(); const queue = entries.map((e) => ({ id: e.id, clipId: e.clipId, - position: e.position, - scheduledAt: e.scheduledAt.toISOString(), - sharesIn: formatRelativeTime(Math.max(0, e.scheduledAt.getTime() - now)), createdAt: e.createdAt.toISOString(), title: e.title, originalUrl: e.originalUrl, @@ -23,7 +23,7 @@ export const GET: RequestHandler = withAuth(async (_event, { user }) => { thumbnailPath: e.thumbnailPath })); - return json({ queue }); + return json({ queue, total, hasMore: offset + queue.length < total }); }); export const DELETE: RequestHandler = withAuth(async (_event, { user }) => { diff --git a/src/routes/api/queue/[id]/move-to-top/+server.ts b/src/routes/api/queue/[id]/move-to-top/+server.ts deleted file mode 100644 index f66d459..0000000 --- a/src/routes/api/queue/[id]/move-to-top/+server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth, notFound } from '$lib/server/api-utils'; -import { moveToTop } from '$lib/server/queue'; - -export const POST: RequestHandler = withAuth(async ({ params }, { user }) => { - const success = moveToTop(params.id, user.id, user.groupId); - if (!success) return notFound('Queue entry not found'); - return json({ ok: true }); -}); diff --git a/src/routes/api/queue/post/+server.ts b/src/routes/api/queue/post/+server.ts new file mode 100644 index 0000000..81d0b04 --- /dev/null +++ b/src/routes/api/queue/post/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { withAuth, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; +import { postQueuedClips, getQueueCount } from '$lib/server/queue'; +import { getCredits } from '$lib/server/credits'; + +/** + * Post selected queued clips to the feed. Spends one credit per clip; the server + * self-limits to the user's available credits (extra ids are silently skipped once + * credits run out). Only the caller's own 'queued' entries are eligible. + */ +export const POST: RequestHandler = withAuth(async ({ request }, { user }) => { + const body = await parseBody<{ ids?: unknown }>(request); + if (isResponse(body)) return body; + + const ids = Array.isArray(body.ids) + ? body.ids.filter((v): v is string => typeof v === 'string') + : []; + if (ids.length === 0) return badRequest('No queue entries provided'); + + const posted = await postQueuedClips(user.id, user.groupId, ids); + + return json({ + posted, + credits: getCredits(user.id), + queueLength: getQueueCount(user.id, user.groupId) + }); +}); diff --git a/src/routes/api/queue/reorder/+server.ts b/src/routes/api/queue/reorder/+server.ts deleted file mode 100644 index 4bdea3a..0000000 --- a/src/routes/api/queue/reorder/+server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { withAuth, parseBody, isResponse, badRequest } from '$lib/server/api-utils'; -import { reorderQueue } from '$lib/server/queue'; - -export const PATCH: RequestHandler = withAuth(async ({ request }, { user }) => { - const body = await parseBody<{ orderedIds?: string[] }>(request); - if (isResponse(body)) return body; - - const { orderedIds } = body; - if (!Array.isArray(orderedIds) || orderedIds.length === 0) { - return badRequest('orderedIds must be a non-empty array of queue entry IDs.'); - } - - const success = reorderQueue(user.id, user.groupId, orderedIds); - if (!success) return badRequest('Invalid queue entry IDs or count mismatch.'); - - return json({ ok: true }); -}); diff --git a/src/routes/join/[code]/+page.server.ts b/src/routes/join/[code]/+page.server.ts index 16ee474..7b00287 100644 --- a/src/routes/join/[code]/+page.server.ts +++ b/src/routes/join/[code]/+page.server.ts @@ -5,6 +5,7 @@ import { db } from '$lib/server/db'; import { users, notificationPreferences } from '$lib/server/db/schema'; import { v4 as uuid } from 'uuid'; import { checkRateLimit } from '$lib/server/rate-limit'; +import { getReciprocityConfig } from '$lib/server/credits'; export const load: PageServerLoad = async ({ params }) => { const group = await validateInviteCode(params.code); @@ -40,6 +41,7 @@ export const actions: Actions = { username: '', phone: placeholderPhone, groupId: group.id, + postCredits: getReciprocityConfig(group.id).seed, createdAt: new Date() }); diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index b24f949..af015a9 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -1,5 +1,4 @@