mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87309101ca | ||
|
|
ba3b37989f |
No files matched your search
@@ -1,317 +0,0 @@
|
||||
-- Initial editorial dare catalogue. Catalogue IDs are deterministic so the seed
|
||||
-- is replay-safe. Only rows that passed the universal store-safety review are
|
||||
-- public and `allowed`; all other editorial inventory remains fail-closed.
|
||||
|
||||
WITH seed (
|
||||
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason
|
||||
) AS (VALUES
|
||||
('1f5e0000-0000-4000-8000-100000000001', 'Little Things', 'Small gestures that make your partner feel seen.', '💌', false, true, false, 10, 1, 1, 1.00, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||
('1f5e0000-0000-4000-8000-100000000002', 'Make Me Laugh', 'Silly challenges for a shared laugh.', '😄', false, true, false, 20, 1, 2, 1.33, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||
('1f5e0000-0000-4000-8000-100000000003', 'Sweet & Romantic', 'Thoughtful dares for feeling close.', '💕', false, true, false, 30, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual romantic content'),
|
||||
('1f5e0000-0000-4000-8000-100000000004', 'Surprise Me', 'Plan a little surprise for your partner.', '🎁', true, true, false, 40, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual relationship content'),
|
||||
('1f5e0000-0000-4000-8000-100000000005', 'Flirty Messages', 'Private messages with a little spark.', '💬', true, false, false, 50, 2, 3, 2.47, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-100000000006', 'Long-Distance Heat', 'Private dares for partners apart.', '📱', true, false, true, 60, 2, 3, 2.67, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-100000000007', 'Risky Photos & Audio', 'Private photo and audio dares.', '📸', true, false, true, 70, 3, 4, 3.53, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-100000000008', 'Hands On', 'Private in-person intimacy dares.', '🫶', true, false, true, 80, 3, 4, 3.40, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-100000000009', 'After Dark', 'Private adult intimacy dares.', '🌙', true, false, true, 90, 4, 4, 4.00, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-100000000010', 'Power Play', 'Private adult power-play dares.', '🔒', true, false, true, 100, 5, 5, 5.00, 'unreviewed', 'Private mature editorial inventory')
|
||||
), upsert AS (
|
||||
INSERT INTO dare_packs (
|
||||
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason,
|
||||
content_reviewed_at
|
||||
)
|
||||
SELECT id::uuid, name, description, icon, is_premium, is_public, is_explicit, sort_order,
|
||||
min_intensity, max_intensity, avg_intensity::numeric(3, 2),
|
||||
content_status::content_review_status, content_review_reason,
|
||||
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
|
||||
FROM seed
|
||||
ON CONFLICT (id) DO UPDATE SET id = dare_packs.id
|
||||
WHERE (dare_packs.name, dare_packs.description, dare_packs.icon,
|
||||
dare_packs.is_premium, dare_packs.is_public, dare_packs.is_explicit,
|
||||
dare_packs.sort_order, dare_packs.category_id, dare_packs.min_intensity,
|
||||
dare_packs.max_intensity, dare_packs.avg_intensity, dare_packs.content_status,
|
||||
dare_packs.content_review_reason, dare_packs.content_reviewed_at,
|
||||
dare_packs.content_reviewed_by)
|
||||
IS NOT DISTINCT FROM
|
||||
(EXCLUDED.name, EXCLUDED.description, EXCLUDED.icon, EXCLUDED.is_premium,
|
||||
EXCLUDED.is_public, EXCLUDED.is_explicit, EXCLUDED.sort_order,
|
||||
EXCLUDED.category_id, EXCLUDED.min_intensity, EXCLUDED.max_intensity,
|
||||
EXCLUDED.avg_intensity, EXCLUDED.content_status,
|
||||
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
|
||||
EXCLUDED.content_reviewed_by)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||
--> statement-breakpoint
|
||||
|
||||
WITH seed (id, pack_id, text, intensity, content_status, content_review_reason) AS (VALUES
|
||||
-- Little Things
|
||||
('1f5e0000-0000-4000-8000-000000000001', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a photo of something that reminded you of them.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000002', '1f5e0000-0000-4000-8000-100000000001', 'Hide a short note somewhere your partner will unexpectedly find it.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000003', '1f5e0000-0000-4000-8000-100000000001', 'Bring your partner their favourite snack without asking what they want.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000004', '1f5e0000-0000-4000-8000-100000000001', 'Take over the household job your partner hates most.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000005', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a drink exactly how they like it.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000006', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a compliment that has nothing to do with their appearance.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000007', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a proper uninterrupted hug.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000008', '1f5e0000-0000-4000-8000-100000000001', 'Choose a shared photo and tell your partner why you still love that moment.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000009', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a three-song playlist for their current mood.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000010', '1f5e0000-0000-4000-8000-100000000001', 'Leave your partner a voice note they can replay when they are having a bad day.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000011', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a homemade voucher for one chore-free evening.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000012', '1f5e0000-0000-4000-8000-100000000001', 'Put your phone away and give your partner your full attention for thirty minutes.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000013', '1f5e0000-0000-4000-8000-100000000001', 'Tell your partner about the first moment you realised they were special.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000014', '1f5e0000-0000-4000-8000-100000000001', 'Fix one small annoyance your partner has been putting off.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000015', '1f5e0000-0000-4000-8000-100000000001', 'Let your partner choose what you watch, play, or listen to together.', 1, 'allowed', 'Reviewed with Little Things pack'),
|
||||
-- Make Me Laugh
|
||||
('1f5e0000-0000-4000-8000-000000000016', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner the worst selfie you can take.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000017', '1f5e0000-0000-4000-8000-100000000002', 'Record a ten-second impression of your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000018', '1f5e0000-0000-4000-8000-100000000002', 'Draw your partner from memory and send them the result.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000019', '1f5e0000-0000-4000-8000-100000000002', 'Make a meme about your relationship.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000020', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner your cheesiest pickup line.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000021', '1f5e0000-0000-4000-8000-100000000002', 'Invent a ridiculous new nickname for your partner and use it until the dare expires.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000022', '1f5e0000-0000-4000-8000-100000000002', 'Record a dramatic advert explaining why someone should date your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000023', '1f5e0000-0000-4000-8000-100000000002', 'Tell your partner a painfully bad joke without laughing.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000024', '1f5e0000-0000-4000-8000-100000000002', 'Recreate one of your partner''s photos as badly as possible.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000025', '1f5e0000-0000-4000-8000-100000000002', 'Describe your relationship using only five emojis.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000026', '1f5e0000-0000-4000-8000-100000000002', 'Perform a thirty-second dance chosen by your partner.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000027', '1f5e0000-0000-4000-8000-100000000002', 'Wear a ridiculous accessory until your partner releases you from the dare.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000028', '1f5e0000-0000-4000-8000-100000000002', 'Serenade your partner with the least romantic song you can find.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000029', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner an embarrassing but harmless confession.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000030', '1f5e0000-0000-4000-8000-100000000002', 'Act out how you behaved on your first date.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
|
||||
-- Sweet & Romantic
|
||||
('1f5e0000-0000-4000-8000-000000000031', '1f5e0000-0000-4000-8000-100000000003', 'Write your partner a love note by hand.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000032', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a song that says what you struggle to put into words.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000033', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner three small things they do that make your life better.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000034', '1f5e0000-0000-4000-8000-100000000003', 'Plan an at-home date and keep the details secret.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000035', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a slow dance without waiting for the perfect song.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000036', '1f5e0000-0000-4000-8000-100000000003', 'Recreate one detail from your first date together.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000037', '1f5e0000-0000-4000-8000-100000000003', 'Kiss your partner like you have not seen them for a month.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000038', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a voice note describing your favourite memory of them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000039', '1f5e0000-0000-4000-8000-100000000003', 'Prepare your partner''s side of the bed exactly how they like it.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000040', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner five kisses, each in a different place.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000041', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner the quality you find most attractive in them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000042', '1f5e0000-0000-4000-8000-100000000003', 'Plan a mystery date and give your partner only one clue.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000043', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a compliment they will still remember next week.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000044', '1f5e0000-0000-4000-8000-100000000003', 'Read your partner a message you sent during the early days of your relationship.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000045', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner ten minutes of affection without looking at either of your phones.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
|
||||
-- Surprise Me
|
||||
('1f5e0000-0000-4000-8000-000000000046', '1f5e0000-0000-4000-8000-100000000004', 'Leave your partner''s favourite treat somewhere they will discover it.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000047', '1f5e0000-0000-4000-8000-100000000004', 'Complete one job your partner expects to do themselves.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000048', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner an unexpected food delivery.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000049', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a mystery date and tell your partner only when to be ready.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000050', '1f5e0000-0000-4000-8000-100000000004', 'Hide three clues leading your partner to a small surprise.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000051', '1f5e0000-0000-4000-8000-100000000004', 'Take your partner somewhere neither of you has visited before.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000052', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner flowers or their preferred equivalent for no particular reason.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000053', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner coordinates and ask them to meet you there.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000054', '1f5e0000-0000-4000-8000-100000000004', 'Create a playlist that reveals what you have planned.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000055', '1f5e0000-0000-4000-8000-100000000004', 'Recreate your partner''s ideal lazy morning.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000056', '1f5e0000-0000-4000-8000-100000000004', 'Put together a date using only things already in your home.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000057', '1f5e0000-0000-4000-8000-100000000004', 'Choose an outfit for your partner from clothes they already own.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000058', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner an envelope they are not allowed to open until the dare expires.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000059', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a surprise based on something your partner mentioned weeks ago.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
('1f5e0000-0000-4000-8000-000000000060', '1f5e0000-0000-4000-8000-100000000004', 'Turn an ordinary meal into a date without warning your partner first.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
|
||||
-- Flirty Messages
|
||||
('1f5e0000-0000-4000-8000-000000000061', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three messages that become progressively flirtier.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000062', '1f5e0000-0000-4000-8000-100000000005', 'Record yourself saying your partner''s name in the most seductive way you can.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000063', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a selfie wearing something you know they like.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000064', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner where you want them to kiss you.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000065', '1f5e0000-0000-4000-8000-100000000005', 'Describe the first thing you notice when your partner enters a room.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000066', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a compliment you would not say in front of anyone else.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000067', '1f5e0000-0000-4000-8000-100000000005', 'Invent a secret phrase that means you want your partner.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000068', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a message designed to make them blush.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000069', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner what you would do if you were alone together.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000070', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three words designed to distract them.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000071', '1f5e0000-0000-4000-8000-100000000005', 'Record a voice note telling your partner what you want later.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000072', '1f5e0000-0000-4000-8000-100000000005', 'Send a close-up photo and make your partner guess what they are looking at.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000073', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner which item of their clothing you most want to remove.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000074', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner one instruction to follow when you next meet.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
('1f5e0000-0000-4000-8000-000000000075', '1f5e0000-0000-4000-8000-100000000005', 'Describe one fantasy in a single sentence without using vague words.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
|
||||
-- Long-Distance Heat
|
||||
('1f5e0000-0000-4000-8000-000000000076', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo showing exactly what you are doing.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000077', '1f5e0000-0000-4000-8000-100000000006', 'Record a goodnight message your partner will want to replay.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000078', '1f5e0000-0000-4000-8000-100000000006', 'Plan a video-call date and send your partner a dress code.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000079', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo of the empty space where you wish they were.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000080', '1f5e0000-0000-4000-8000-100000000006', 'Order your partner a surprise they can enjoy during your next call.', 2, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000081', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a risky selfie within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000082', '1f5e0000-0000-4000-8000-100000000006', 'Record a voice note telling your partner exactly how much you miss their touch.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000083', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo from bed without adding any explanation.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000084', '1f5e0000-0000-4000-8000-100000000006', 'Describe what you would do if your partner appeared beside you.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000085', '1f5e0000-0000-4000-8000-100000000006', 'Give your partner a private fashion show over video.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000086', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a sequence of photos that gradually reveal more.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000087', '1f5e0000-0000-4000-8000-100000000006', 'Record yourself whispering what you want your partner to do when you reunite.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000088', '1f5e0000-0000-4000-8000-100000000006', 'Let your partner choose what you wear during your next private call.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000089', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo they can use as their private wallpaper.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000090', '1f5e0000-0000-4000-8000-100000000006', 'End your next call with a promise your partner can hold you to.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
-- Risky Photos & Audio
|
||||
('1f5e0000-0000-4000-8000-000000000091', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo captioned "Only for you."', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000092', '1f5e0000-0000-4000-8000-100000000007', 'Take a silhouette photo that leaves your partner guessing.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000093', '1f5e0000-0000-4000-8000-100000000007', 'Record a voice note describing what you are wearing.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000094', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo that reveals just enough to make them curious.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000095', '1f5e0000-0000-4000-8000-100000000007', 'Take a photo from your partner''s favourite angle.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000096', '1f5e0000-0000-4000-8000-100000000007', 'Record a risky audio clip for your partner within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000097', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo showing what you plan to wear underneath your clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000098', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a topless photo they have not seen before.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000099', '1f5e0000-0000-4000-8000-100000000007', 'Record yourself describing exactly what you want your partner to do to you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000100', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a nude using lighting or shadows creatively.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000101', '1f5e0000-0000-4000-8000-100000000007', 'Record the sound you make when your partner touches you properly.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000102', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a short striptease video.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000103', '1f5e0000-0000-4000-8000-100000000007', 'Take a nude photo without showing your face.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000104', '1f5e0000-0000-4000-8000-100000000007', 'Record a video showing your partner how badly you want them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000105', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner the most explicit photo you are both comfortable keeping.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
-- Hands On
|
||||
('1f5e0000-0000-4000-8000-000000000106', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a ten-minute massage.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000107', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner in five places without kissing their lips.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000108', '1f5e0000-0000-4000-8000-100000000008', 'Trace your fingertips over your partner''s body for five minutes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000109', '1f5e0000-0000-4000-8000-100000000008', 'Sit on your partner''s lap and kiss them slowly.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000110', '1f5e0000-0000-4000-8000-100000000008', 'Blindfold your partner and feed them something they enjoy.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000111', '1f5e0000-0000-4000-8000-100000000008', 'Let your partner choose where you kiss them.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000112', '1f5e0000-0000-4000-8000-100000000008', 'Tease your partner for ten minutes without touching beneath their clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000113', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a massage and stop exactly when they want more.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000114', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner''s neck until they ask you to stop.', 3, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000115', '1f5e0000-0000-4000-8000-100000000008', 'Remove one item of your partner''s clothing using only one hand.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000116', '1f5e0000-0000-4000-8000-100000000008', 'Guide your partner''s hand to where you want it.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000117', '1f5e0000-0000-4000-8000-100000000008', 'Undress your partner as slowly as possible.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000118', '1f5e0000-0000-4000-8000-100000000008', 'Join your partner in the shower.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000119', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your way down your partner''s body and stop just before reaching their most sensitive spot.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000120', '1f5e0000-0000-4000-8000-100000000008', 'Pleasure your partner for ten minutes without letting them return the favour.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
-- After Dark
|
||||
('1f5e0000-0000-4000-8000-000000000121', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly what you want, then do it together if they want it too.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000122', '1f5e0000-0000-4000-8000-100000000009', 'Give your partner oral sex.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000123', '1f5e0000-0000-4000-8000-100000000009', 'Have sex in a position chosen by your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000124', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate while your partner watches.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000125', '1f5e0000-0000-4000-8000-100000000009', 'Watch your partner masturbate without touching them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000126', '1f5e0000-0000-4000-8000-100000000009', 'Use a sex toy on your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000127', '1f5e0000-0000-4000-8000-100000000009', 'Let your partner use a sex toy on you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000128', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate together while watching each other.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000129', '1f5e0000-0000-4000-8000-100000000009', 'Blindfold your partner and pleasure them.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000130', '1f5e0000-0000-4000-8000-100000000009', 'Have sex without completely removing your clothes.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000131', '1f5e0000-0000-4000-8000-100000000009', 'Take your partner somewhere private for a quickie.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000132', '1f5e0000-0000-4000-8000-100000000009', 'Act out a fantasy your partner has previously shared with you.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000133', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly how to touch you while they do it.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000134', '1f5e0000-0000-4000-8000-100000000009', 'Spend twenty minutes pleasuring each other without penetration.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000135', '1f5e0000-0000-4000-8000-100000000009', 'Make your partner orgasm without using your hands.', 4, 'unreviewed', 'Private mature editorial inventory'),
|
||||
-- Power Play
|
||||
('1f5e0000-0000-4000-8000-000000000136', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner three rules to follow until the dare expires.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000137', '1f5e0000-0000-4000-8000-100000000010', 'Kneel in front of your partner and wait for their first instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000138', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner restrain your wrists using an agreed quick-release restraint.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000139', '1f5e0000-0000-4000-8000-100000000010', 'Wear a blindfold and follow three instructions from your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000140', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner an agreed number of strikes using an agreed implement and intensity.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000141', '1f5e0000-0000-4000-8000-100000000010', 'Act as your partner''s submissive for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000142', '1f5e0000-0000-4000-8000-100000000010', 'Take control of your partner for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000143', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner choose your outfit, position, and posture.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000144', '1f5e0000-0000-4000-8000-100000000010', 'Send your partner a voice note promising to obey one agreed instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000145', '1f5e0000-0000-4000-8000-100000000010', 'Edge your partner and stop before they orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000146', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner decide when you are allowed to orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000147', '1f5e0000-0000-4000-8000-100000000010', 'Wear a collar or agreed symbol of submission for your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000148', '1f5e0000-0000-4000-8000-100000000010', 'Serve your partner without speaking for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000149', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner control a remote toy while you remain somewhere private.', 5, 'unreviewed', 'Private mature editorial inventory'),
|
||||
('1f5e0000-0000-4000-8000-000000000150', '1f5e0000-0000-4000-8000-100000000010', 'Finish the dare by giving your partner the aftercare they ask for.', 5, 'unreviewed', 'Private mature editorial inventory')
|
||||
), upsert AS (
|
||||
INSERT INTO dares (
|
||||
id, pack_id, text, intensity, content_status, content_review_reason,
|
||||
content_reviewed_at
|
||||
)
|
||||
SELECT id::uuid, pack_id::uuid, text, intensity,
|
||||
content_status::content_review_status, content_review_reason,
|
||||
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
|
||||
FROM seed
|
||||
ON CONFLICT (id) DO UPDATE SET id = dares.id
|
||||
WHERE (dares.pack_id, dares.text, dares.intensity, dares.suggested_duration_hours,
|
||||
dares.content_status, dares.content_review_reason, dares.content_reviewed_at,
|
||||
dares.content_reviewed_by)
|
||||
IS NOT DISTINCT FROM
|
||||
(EXCLUDED.pack_id, EXCLUDED.text, EXCLUDED.intensity,
|
||||
EXCLUDED.suggested_duration_hours, EXCLUDED.content_status,
|
||||
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
|
||||
EXCLUDED.content_reviewed_by)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||
--> statement-breakpoint
|
||||
|
||||
-- A migration has no authenticated human reviewer. Record the system seed as the
|
||||
-- actor-less, timestamped review decision the schema explicitly permits rather than
|
||||
-- fabricating a profile UUID. The entries remain durable audit provenance.
|
||||
WITH seed (id, entity_type, entity_id, previous_status, new_status, reason, created_at) AS (
|
||||
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||
'dare_packs'::content_entity_type, id, 'unreviewed'::content_review_status,
|
||||
'allowed'::content_review_status, content_review_reason,
|
||||
'2026-09-05T16:00:00Z'::timestamptz
|
||||
FROM dare_packs
|
||||
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||
UNION ALL
|
||||
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||
'dares'::content_entity_type, id, 'unreviewed'::content_review_status,
|
||||
'allowed'::content_review_status, content_review_reason,
|
||||
'2026-09-05T16:00:00Z'::timestamptz
|
||||
FROM dares
|
||||
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||
), upsert AS (
|
||||
INSERT INTO content_reviews (
|
||||
id, entity_type, entity_id, previous_status, new_status, reason, changed_by, created_at
|
||||
)
|
||||
SELECT id::uuid, entity_type::content_entity_type, entity_id::uuid,
|
||||
previous_status::content_review_status, new_status::content_review_status,
|
||||
reason, null::uuid, created_at::timestamptz
|
||||
FROM seed
|
||||
ON CONFLICT (id) DO UPDATE SET id = content_reviews.id
|
||||
WHERE (content_reviews.entity_type, content_reviews.entity_id,
|
||||
content_reviews.previous_status, content_reviews.new_status,
|
||||
content_reviews.reason, content_reviews.changed_by,
|
||||
content_reviews.created_at)
|
||||
IS NOT DISTINCT FROM
|
||||
(EXCLUDED.entity_type, EXCLUDED.entity_id, EXCLUDED.previous_status,
|
||||
EXCLUDED.new_status, EXCLUDED.reason, EXCLUDED.changed_by,
|
||||
EXCLUDED.created_at)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
|
||||
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
|
||||
--> statement-breakpoint
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM content_reviews r
|
||||
WHERE (r.entity_type = 'dare_packs' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid AND '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||
OR (r.entity_type = 'dares' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid AND '1f5e0000-0000-4000-8000-000000000060'::uuid)
|
||||
GROUP BY r.entity_type, r.entity_id
|
||||
HAVING count(*) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'catalogue seed review provenance is not one-to-one';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
WITH expected (id, entity_type, entity_id, reason) AS (
|
||||
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||
'dare_packs'::content_entity_type, id, content_review_reason
|
||||
FROM dare_packs
|
||||
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||
UNION ALL
|
||||
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
|
||||
'dares'::content_entity_type, id, content_review_reason
|
||||
FROM dares
|
||||
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||
)
|
||||
SELECT 1
|
||||
FROM expected e
|
||||
LEFT JOIN content_reviews r
|
||||
ON r.entity_type = e.entity_type AND r.entity_id = e.entity_id
|
||||
WHERE (r.id, r.previous_status, r.new_status, r.reason, r.changed_by,
|
||||
r.created_at)
|
||||
IS DISTINCT FROM
|
||||
(e.id, 'unreviewed'::content_review_status,
|
||||
'allowed'::content_review_status, e.reason, null::uuid,
|
||||
'2026-09-05T16:00:00Z'::timestamptz)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'catalogue seed review provenance does not match the system seed';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -162,13 +162,6 @@
|
||||
"when": 1788168150000,
|
||||
"tag": "0022_solo_sealed_answers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1788624000000,
|
||||
"tag": "0023_dare_catalogue_150",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||
@@ -85,130 +82,6 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||
});
|
||||
|
||||
it('seeds the 150-item editorial catalogue with fail-closed review states', async () => {
|
||||
const seeded = await pool.query<{
|
||||
name: string; is_premium: boolean; is_public: boolean; is_explicit: boolean;
|
||||
content_status: string; min_intensity: number; max_intensity: number; avg_intensity: string; dare_count: string;
|
||||
}>(`
|
||||
select dp.name, dp.is_premium, dp.is_public, dp.is_explicit, dp.content_status,
|
||||
dp.min_intensity, dp.max_intensity, dp.avg_intensity::text,
|
||||
count(d.id)::text as dare_count
|
||||
from dare_packs dp
|
||||
join dares d on d.pack_id = dp.id
|
||||
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-100000000010'::uuid
|
||||
group by dp.id
|
||||
order by dp.sort_order
|
||||
`);
|
||||
|
||||
expect(seeded.rows).toEqual([
|
||||
{ name: 'Little Things', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 1, avg_intensity: '1.00', dare_count: '15' },
|
||||
{ name: 'Make Me Laugh', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.33', dare_count: '15' },
|
||||
{ name: 'Sweet & Romantic', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
|
||||
{ name: 'Surprise Me', is_premium: true, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
|
||||
{ name: 'Flirty Messages', is_premium: true, is_public: false, is_explicit: false, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.47', dare_count: '15' },
|
||||
{ name: 'Long-Distance Heat', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.67', dare_count: '15' },
|
||||
{ name: 'Risky Photos & Audio', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.53', dare_count: '15' },
|
||||
{ name: 'Hands On', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.40', dare_count: '15' },
|
||||
{ name: 'After Dark', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 4, max_intensity: 4, avg_intensity: '4.00', dare_count: '15' },
|
||||
{ name: 'Power Play', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 5, max_intensity: 5, avg_intensity: '5.00', dare_count: '15' },
|
||||
]);
|
||||
|
||||
const distribution = await pool.query<{
|
||||
total: string; free: string; premium: string; allowed: string; unreviewed: string; duplicate_texts: string; stat_mismatches: string;
|
||||
}>(`
|
||||
select count(*)::text as total,
|
||||
count(*) filter (where not dp.is_premium)::text as free,
|
||||
count(*) filter (where dp.is_premium)::text as premium,
|
||||
count(*) filter (where d.content_status = 'allowed')::text as allowed,
|
||||
count(*) filter (where d.content_status = 'unreviewed')::text as unreviewed,
|
||||
(select count(*)::text from (
|
||||
select text from dares
|
||||
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-000000000150'::uuid
|
||||
group by text having count(*) > 1
|
||||
) duplicates) as duplicate_texts,
|
||||
(select count(*)::text from (
|
||||
select dp.id
|
||||
from dare_packs dp
|
||||
join dares expected on expected.pack_id = dp.id
|
||||
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-100000000010'::uuid
|
||||
group by dp.id
|
||||
having dp.min_intensity is distinct from min(expected.intensity)
|
||||
or dp.max_intensity is distinct from max(expected.intensity)
|
||||
or dp.avg_intensity is distinct from round(avg(expected.intensity)::numeric, 2)
|
||||
) mismatches) as stat_mismatches
|
||||
from dares d
|
||||
join dare_packs dp on dp.id = d.pack_id
|
||||
where d.id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-000000000150'::uuid
|
||||
`);
|
||||
expect(distribution.rows[0]).toEqual({ total: '150', free: '45', premium: '105', allowed: '60', unreviewed: '90', duplicate_texts: '0', stat_mismatches: '0' });
|
||||
|
||||
const provenance = await pool.query<{ reviewed_rows: string; review_records: string; attributed_records: string }>(`
|
||||
select
|
||||
(select count(*)::text from (
|
||||
select content_reviewed_at
|
||||
from dare_packs
|
||||
where id between '1f5e0000-0000-4000-8000-100000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-100000000004'::uuid
|
||||
union all
|
||||
select content_reviewed_at
|
||||
from dares
|
||||
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
|
||||
and '1f5e0000-0000-4000-8000-000000000060'::uuid
|
||||
) reviewed where content_reviewed_at = '2026-09-05T16:00:00Z'::timestamptz)::text as reviewed_rows,
|
||||
(select count(*)::text from content_reviews
|
||||
where (entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid)) as review_records,
|
||||
(select count(*)::text from content_reviews
|
||||
where ((entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
|
||||
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid))
|
||||
and changed_by is not null) as attributed_records
|
||||
`);
|
||||
expect(provenance.rows[0]).toEqual({ reviewed_rows: '64', review_records: '64', attributed_records: '0' });
|
||||
});
|
||||
|
||||
it('is discovered by Drizzle and fails closed on a conflicting catalogue ID', async () => {
|
||||
const migrationSchema = `dare_migrator_${randomUUID().replaceAll('-', '')}`;
|
||||
await admin.query(`create schema "${migrationSchema}"`);
|
||||
const migrationUrl = new URL(url!);
|
||||
migrationUrl.searchParams.set('options', `-c search_path=${migrationSchema}`);
|
||||
const migrationPool = new Pool({ connectionString: migrationUrl.toString() });
|
||||
const dir = new URL('../drizzle/', import.meta.url);
|
||||
|
||||
try {
|
||||
await migrate(drizzle(migrationPool), { migrationsFolder: fileURLToPath(dir) });
|
||||
const discovered = await migrationPool.query<{ count: string }>(
|
||||
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
|
||||
);
|
||||
expect(discovered.rows[0]?.count).toBe('150');
|
||||
|
||||
const seed = await readFile(new URL('0023_dare_catalogue_150.sql', dir), 'utf8');
|
||||
const applySeed = async () => {
|
||||
for (const statement of seed.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await migrationPool.query(statement);
|
||||
}
|
||||
};
|
||||
await applySeed();
|
||||
await migrationPool.query(
|
||||
"update content_reviews set reason = 'Conflicting review' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||
);
|
||||
await expect(applySeed()).rejects.toThrow(/division by zero|review provenance/);
|
||||
await migrationPool.query(
|
||||
"update content_reviews set reason = 'Reviewed with Little Things pack' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||
);
|
||||
await migrationPool.query(
|
||||
"update dares set text = 'Conflicting dare' where id = '1f5e0000-0000-4000-8000-000000000001'::uuid",
|
||||
);
|
||||
await expect(applySeed()).rejects.toThrow(/division by zero/);
|
||||
} finally {
|
||||
await migrationPool.end();
|
||||
await admin.query(`drop schema if exists "${migrationSchema}" cascade`);
|
||||
}
|
||||
});
|
||||
|
||||
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface JournalEntry {
|
||||
idx: number;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
describe('Drizzle migration journal', () => {
|
||||
it('lists every ordered SQL migration exactly once', async () => {
|
||||
const migrationsDirectory = new URL('../drizzle/', import.meta.url);
|
||||
const journal = JSON.parse(await readFile(new URL('meta/_journal.json', migrationsDirectory), 'utf8')) as {
|
||||
entries: JournalEntry[];
|
||||
};
|
||||
const migrationFiles = (await readdir(migrationsDirectory))
|
||||
.filter((name) => name.endsWith('.sql'))
|
||||
.sort();
|
||||
const journalFiles = journal.entries.map((entry) => `${entry.tag}.sql`);
|
||||
|
||||
expect(journal.entries.map((entry) => entry.idx)).toEqual([...journal.entries.keys()]);
|
||||
expect(new Set(journalFiles).size).toBe(journalFiles.length);
|
||||
expect(journalFiles).toEqual(migrationFiles);
|
||||
});
|
||||
});
|
||||
@@ -75,36 +75,6 @@ notification preview, and `dares.integration.test.ts` asserts no payload leaks i
|
||||
## History durability
|
||||
|
||||
`sent_dares` snapshots `dare_text_snapshot` and `dare_intensity_snapshot` at send time.
|
||||
|
||||
## Editorial catalogue seed
|
||||
|
||||
Migration `0023_dare_catalogue_150.sql` seeds ten deterministic-ID packs and 150
|
||||
dares. It is replay-safe only when the existing rows exactly match the seed; a
|
||||
deterministic-ID collision with changed content aborts the migration. The seed does
|
||||
not change the dare schema, API, or mobile behaviour.
|
||||
|
||||
Only the 60 non-sexual rows in these packs are public and `allowed`:
|
||||
|
||||
- Little Things, Make Me Laugh, and Sweet & Romantic: 45 free dares.
|
||||
- Surprise Me: 15 premium dares.
|
||||
|
||||
Flirty Messages (15 premium rows) is non-public and `unreviewed` until an
|
||||
editorial line review records an allowed decision. Long-Distance Heat, Risky
|
||||
Photos & Audio, Hands On, After Dark, and Power Play (75 premium rows) are
|
||||
explicit, non-public, and `unreviewed`. They are private editorial inventory and
|
||||
cannot be returned by the current fail-closed catalogue queries.
|
||||
|
||||
The 60 `allowed` rows carry a fixed migration review timestamp and a matching
|
||||
`content_reviews` record. `changed_by` is null because a migration has no
|
||||
authenticated human actor; the audit reason and timestamp identify this as the
|
||||
system seed decision rather than attributing it to a fabricated profile. Exact
|
||||
replay verifies this audit provenance too, so a divergent deterministic review ID
|
||||
blocks the migration rather than being silently accepted.
|
||||
|
||||
The sender chooses a duration when sending a dare. Approved catalogue rows may
|
||||
refer to "until the dare expires", which resolves to that sender-selected deadline.
|
||||
The two private, unreviewed "within the next hour" rows remain unavailable until
|
||||
their editorial review also confirms the sending experience communicates that limit.
|
||||
Editing, archiving, or deleting a catalogue dare never rewrites what a couple was
|
||||
actually dared. This also fixes a latent defect: `dare_id` is `ON DELETE SET NULL`, and
|
||||
the old `sent_dares_dare_or_custom_check` made deleting any dare that had been sent fail
|
||||
|
||||
@@ -36,7 +36,6 @@ partially implemented states. Confirm claims against current code.
|
||||
|
||||
- `admin-question-types-plan.md`
|
||||
- [Universal catalogue safety plan](product/catalog-content-safety-plan.md)
|
||||
- [Sex and fun relationship market strategy](product/sex-fun-market-strategy.md)
|
||||
- `content-balance-implementation.md`
|
||||
- `dares-feature.md` (superseded by [Dares loop](dares-loop.md))
|
||||
- `live-draw-feature-plan.md`
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,215 +0,0 @@
|
||||
# Sex and fun relationship market strategy
|
||||
|
||||
**Research date:** 2026-09-05
|
||||
**Decision:** Position Sauci as a private mutual-discovery product for adult
|
||||
couples, then make a mutual match an optional real-world action.
|
||||
|
||||
## Scope and evidence limits
|
||||
|
||||
This is product-direction research, not a legal opinion, store-submission
|
||||
assessment, competitor content scrape, or paid-subscription teardown. It uses
|
||||
public product pages and store metadata, a public filing, official platform
|
||||
policies, and a small number of public Reddit discussions. Reddit is anecdotal,
|
||||
may include self-promotion, and is useful for hypotheses rather than market
|
||||
sizing or claims about a gated catalogue.
|
||||
|
||||
Desire publicly advertises a paid "Chili" level and hotter challenges. This
|
||||
research did not verify the prompts in that tier, so a claim that it contains
|
||||
policy-breaking material is unsupported.
|
||||
|
||||
The repository's universal catalogue safety document is a plan, not proof that
|
||||
all current question recommendation and pack APIs enforce `content_status`.
|
||||
Current dare catalogue and send paths do enforce reviewed content. Universal
|
||||
question enforcement remains future work described by the
|
||||
[catalogue safety plan](catalog-content-safety-plan.md).
|
||||
|
||||
## Market map
|
||||
|
||||
| Segment and representative | Publicly visible proposition | Strategic read | Sauci opportunity |
|
||||
| --- | --- | --- | --- |
|
||||
| Relationship wellness: Paired, Agape, Couply | Daily questions, guided connection, habits, and quizzes. | Can feel like maintenance or therapy. | Keep emotional safety, but make the output a shared possibility. |
|
||||
| Progressive couples game: Desire | Points, dares, categories, paid access, and a "Chili" level. | The paid progression model is visible. The risk of uneven participation is a hypothesis drawn from anecdotal reports. | Test mutual interest first; retain editable, optional follow-on action. |
|
||||
| Explicit single-device sex games | Position, dice, foreplay, truth-or-dare, and challenge catalogues on one device. | Direct novelty but generic couple context and a higher policy burden. | Own two-person discovery and communication, not a graphic instruction catalogue. |
|
||||
| Former Official | Public reports describe private mutual selection of date and intimacy ideas. Bumble later closed it within portfolio changes. | Mutual discovery is a product hypothesis worth testing; closure does not establish that the mechanic failed. | Test private matching as the core promise, then provide a next action. |
|
||||
| Sauci | Couple-isolated questions, reciprocal matches, chat, audio/photo responses, and dares exist. | The primitives exist but need a clearer journey and adult-content guardrails. | "Find something you both genuinely want to try, without either person having to risk a direct no." |
|
||||
|
||||
### External sources by evidence type
|
||||
|
||||
- **Developer marketing claims:** [Paired](https://www.paired.com/),
|
||||
[Agape](https://www.getdailyagape.com/), [Couply](https://www.couply.app/),
|
||||
and [Desire](https://www.desire.games/) describe their own products. They do
|
||||
not establish engagement or efficacy.
|
||||
- **Store metadata:** [Desire on Google Play](https://play.google.com/store/apps/details?id=com.desireapps.desire)
|
||||
and [Desire on the App Store](https://apps.apple.com/us/app/desire-couples-game/id923073855)
|
||||
describe published availability, rating, and paid-tier language.
|
||||
- **Public filing:** [Bumble's 2024 annual report](https://s202.q4cdn.com/372973788/files/doc_financials/2024/q4/BMBL-10K-Q4-2024.pdf)
|
||||
records Official's closure in the context of portfolio changes.
|
||||
- **Anecdotal Reddit evidence:** [Desire and Spicer discussion](https://www.reddit.com/r/DeadBedrooms/comments/1q7gb6h/desire_couples_game_app_or_spicer_app_seeking/)
|
||||
and [couples-app discussion](https://www.reddit.com/r/apps/comments/1qao3tr/any_good_couples_apps_youd_recommend/)
|
||||
contain first-hand opinions, but are small and unrepresentative.
|
||||
|
||||
### Store metadata snapshot
|
||||
|
||||
The following Google Play figures were captured during this research on
|
||||
2026-09-05. They are store metadata, not independently audited market share,
|
||||
and can change at any time. No pricing is inferred.
|
||||
|
||||
| App | Google Play installs | Rating | Reviews | Use in this strategy |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Desire | 1M+ | 4.4 | 20.9K | Progressive couples game reference. |
|
||||
| Paired | 1M+ | 4.6 | 63.3K | Relationship-wellness reference. |
|
||||
| Agape | 500K+ | 4.1 | 9.55K | Relationship-wellness reference. |
|
||||
| Couply | 100K+ | 4.4 | 1.95K | Relationship-wellness reference. |
|
||||
| Lovify | 1M+ | 4.7 | 21.7K | Adjacent couples-app reference. |
|
||||
|
||||
Store pages: [Desire](https://play.google.com/store/apps/details?id=com.desireapps.desire),
|
||||
[Paired](https://play.google.com/store/apps/details?id=com.getpaired.app),
|
||||
[Agape](https://play.google.com/store/apps/details?id=com.getdailyagape),
|
||||
[Couply](https://play.google.com/store/apps/details?id=io.couply.android), and
|
||||
[Lovify](https://play.google.com/store/apps/details?id=com.mindsets.lovify). Explicit
|
||||
Apple-only game entries remain qualitative because comparable Play install
|
||||
metadata is unavailable.
|
||||
|
||||
## Target customer
|
||||
|
||||
The primary customer is an adult couple with a viable relationship who wants
|
||||
more novelty, flirtation, or intimacy, but finds direct initiation of an
|
||||
unfamiliar idea awkward. They need privacy around an unmatched interest, control
|
||||
over intensity, and a route from curiosity to a mutually chosen action.
|
||||
|
||||
Initial exclusions are couples seeking clinical repair or coercion intervention,
|
||||
a solitary-arousal or explicit-instruction catalogue, a product that assigns
|
||||
sexual tasks from one partner to the other, and all minor or adult/minor use.
|
||||
This is scope control, not a judgment about those users.
|
||||
|
||||
## Current technical foundation
|
||||
|
||||
| Existing foundation | Exact repository evidence | Product use |
|
||||
| --- | --- | --- |
|
||||
| Swipe, text answer, audio, photo, and who-likely questions | [Question interaction contract](../question-types.md), [shared type](../../packages/shared/src/types/index.ts) | Private preference selection, written invitations, voice-led prompts, and playful questions. |
|
||||
| Reciprocal match semantics and response summaries | [Question interaction contract](../question-types.md), [match calculation](../../apps/api/src/domains/answers/types.ts) | Reveal only a mutual interest or both-partner response. |
|
||||
| Sender-to-recipient dares with decline, cancel, expiry, and audio/photo proof | [Dares loop](../dares-loop.md), [dare repository](../../apps/api/src/domains/dares/repository.ts) | Existing optional challenge flow, not a shared-action model. |
|
||||
| Couple-shared premium entitlement | [Dares entitlement](../dares-loop.md), [subscription system](../subscription-system.md) | Offer a shared upgrade without paywalling a received invite. |
|
||||
| Dare push payloads and reviewed-dare gating | [Dares safety rules](../dares-loop.md) | Keep dare text out of notification previews and unreviewed dares out of clients. |
|
||||
| JavaScript and asset OTA delivery | [Mobile OTA policy](../mobile-ota.md) | Ship compatible client UI, copy, logic, and assets without a new binary. |
|
||||
|
||||
The [catalogue safety plan](catalog-content-safety-plan.md) defines the intended
|
||||
developer-authored content ceiling: no sexual acts, instructions, fetish content,
|
||||
or material intended for sexual gratification. It states that age confirmation,
|
||||
intensity, premium access, and hidden categories must not override that ceiling.
|
||||
It is future universal question/pack enforcement work. Do not describe it as
|
||||
currently enforced for question recommendation or pack APIs.
|
||||
|
||||
## Product positioning and architecture
|
||||
|
||||
> Sauci helps couples turn unspoken curiosity into things they both want to try.
|
||||
|
||||
Lead with mutuality, privacy, and choice, not content count, coins, or a promise
|
||||
of explicit material. Organise approved content by desired outcome: flirt and
|
||||
anticipate; touch and sensation; take the lead; give up control; tell me
|
||||
privately; try something new; fantasy without assumption; setting and
|
||||
atmosphere; playful roles; long-distance desire; boundaries and comfort;
|
||||
aftercare and reconnection; create your own; and matches for later.
|
||||
|
||||
Developer-authored wording should remain non-graphic and choice-based.
|
||||
Couple-authored detail is UGC, not a policy workaround. The proposed product
|
||||
rule is that new content remains unreviewed and ineligible until a recorded
|
||||
editorial decision allows it; universal question/pack enforcement is not yet
|
||||
current behaviour.
|
||||
|
||||
```text
|
||||
Choose a mood and shared comfort ceiling
|
||||
-> privately answer a short set
|
||||
-> reveal only a mutual interest or both-partner response
|
||||
-> talk, save, or create an optional dare
|
||||
-> complete, defer, decline, or withdraw without penalty
|
||||
```
|
||||
|
||||
Do not disclose which partner chose the lower comfort ceiling. This is a desired
|
||||
future product rule, not an existing session contract. Today the system has a
|
||||
persistent `max_intensity` preference and global selection gates; it does not
|
||||
have a per-session shared comfort record or a mutual private-ceiling protocol.
|
||||
Likewise, a distinct defer, softer alternative, or withdrawal state needs a
|
||||
domain contract rather than a client label alone.
|
||||
|
||||
## One-week delivery plan
|
||||
|
||||
**Current OTA blocker:** the maintained [OTA policy](../mobile-ota.md) says a
|
||||
new store bootstrap build is required before updates can flow. Consequently none
|
||||
of these changes can currently reach users by OTA. After that bootstrap build,
|
||||
client-only prototyping may be OTA-compatible when it stays within the installed
|
||||
native surface. The policy still requires a new store build for native code,
|
||||
dependencies, native configuration, or generated iOS/Android changes.
|
||||
|
||||
Server-side catalogue/configuration activity is separate operational work, not
|
||||
an OTA bundle.
|
||||
|
||||
| Priority | Change | OTA UI/client work | Server-side data/config | Boundary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Quick Spark prototype | Mood entry, 5-8 card session, completion, and mutuality explainer. | Curate only currently eligible question inventory after its enforcement status is verified. | Client prototype may be OTA-compatible after bootstrap; proper reviewed-question enforcement is future API work. |
|
||||
| 2 | Match-to-shared-action design | Add a non-committing Talk or Save affordance. | Design a match-linked shared-action domain model. | True shared action is API/domain work. Current dares assign a sender and recipient. |
|
||||
| 3 | Shared comfort design | Prototype explanatory UI only. | Persist a per-session private selection and calculate a shared ceiling server-side. | Backend/data work. Current `max_intensity` and global gates are not a session protocol. |
|
||||
| 4 | Tell Me Privately prototype | Curate text/audio presentation with both-partner reveal copy. | Verify question eligibility and review any activated prompts. | Client work may be OTA-compatible after bootstrap; no claim of new enforcement. |
|
||||
| 5 | Defer and withdrawal design | Improve wording only where existing decline/cancel actions remain truthful. | Add distinct defer, softer alternative, and withdrawal states with no-penalty semantics. | Backend/data work, not an OTA-only change. |
|
||||
|
||||
Do not call a change OTA-deliverable until the bootstrap build has shipped and
|
||||
the final diff stays inside the installed native surface. Catalogue activation
|
||||
needs its own review, staged rollout, and rollback plan.
|
||||
|
||||
## Store-policy and UGC boundary
|
||||
|
||||
Google Play prohibits content intended to be sexually gratifying and identifies
|
||||
explicit sexual text and sex guides among common violation types. Apple
|
||||
prohibits overtly sexual or pornographic material. Use the stricter Google
|
||||
ceiling for one cross-platform developer-authored catalogue.
|
||||
|
||||
- [Google Play sexual content policy](https://support.google.com/googleplay/android-developer/answer/9878810)
|
||||
- [Apple App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Google Play UGC policy](https://support.google.com/googleplay/android-developer/answer/9876937)
|
||||
|
||||
Subscription, an 18+ label, intensity setting, content flag, or private screen
|
||||
is not a policy exception. Paid functionality must be compliant and reviewable.
|
||||
|
||||
Text, audio, photo, custom dare, and chat create UGC exposure. Before expanding
|
||||
adult-oriented user-authored interactions, verify effective reporting and
|
||||
blocking, terms and enforcement, moderation operations, rate limits, safe push
|
||||
previews, and an incident route for non-consensual or illegal content. The
|
||||
catalogue safety plan requires these controls for user-authored fields; their
|
||||
actual implementation status must be checked before marketing relies on them.
|
||||
|
||||
## Gaps and measurement
|
||||
|
||||
Before a broader adult-content launch: complete the universal question/pack
|
||||
`content_status` enforcement specified by the catalogue plan; verify or
|
||||
implement age assurance and accurate store ratings; add private session comfort
|
||||
and durable interest/maybe/boundary states; make withdrawal, deferral, and
|
||||
softer alternatives explicit in a shared-action contract; verify moderation for
|
||||
every UGC surface; and establish reviewed editorial audit for every
|
||||
developer-authored prompt, pack, and dare.
|
||||
|
||||
Measure aggregate, privacy-preserving events only. Do not collect prompt text,
|
||||
response text, audio, photo content, or intimate match detail in analytics.
|
||||
|
||||
| Funnel stage | Measure | Decision |
|
||||
| --- | --- | --- |
|
||||
| Entry | Starts by mood and shared ceiling | Which framing gets couples into the loop. |
|
||||
| Participation | Both-partner completion and time to second response | Whether the mode gets reciprocal participation. |
|
||||
| Mutuality | Match rate by reviewed content group and intensity | Whether selection is calibrated. |
|
||||
| Action | Match-to-talk, save, dare-draft, send, and completion rates | Whether discovery creates a wanted next step. |
|
||||
| Safety | Skip, defer, decline, cancel, report, and block rates without content payloads | Whether a category creates pressure or risk. |
|
||||
| Retention | Another short session within 7 and 28 days | Whether the loop survives novelty. |
|
||||
|
||||
Do not optimise only for sends or completions. High decline, report, or
|
||||
withdrawal is a safety signal, not a conversion problem to suppress.
|
||||
|
||||
## Decision gates
|
||||
|
||||
1. Approve the positioning and mutual-match-to-optional-action hypothesis.
|
||||
2. Ship and verify the required OTA bootstrap store build before treating any
|
||||
client change as OTA deployable.
|
||||
3. Split shared action, session comfort, and durable consent states into API and
|
||||
data work rather than representing them as client-only changes.
|
||||
4. Audit age assurance and UGC safeguards before marketing adult-oriented
|
||||
custom media or prompts.
|
||||
5. Complete universal question/pack review enforcement before activating new
|
||||
adult-oriented developer-authored catalogue content.
|
||||
@@ -1,117 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const snapshotPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.json');
|
||||
const checksumPath = resolve(root, 'apps/supabase/catalog-snapshots/production-2026-08-28.sha256');
|
||||
const v1Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-v1.json');
|
||||
const v2Path = resolve(root, 'docs/product/question-drafts/play-safe-spicy-extension-v2.json');
|
||||
const markdownPath = resolve(root, 'docs/product/question-drafts/full-live-catalogue-1650.md');
|
||||
|
||||
const allowPacks = new Set([
|
||||
'7e9cb72e-95dd-4140-8d58-17206e6fe153','8da8dcb1-0b9b-4828-9a30-199758b8ade5','e11bdfea-ab07-46a9-952d-5c00c8b4ddbb','cf483ec0-502a-41ea-a2a2-2f223e7ec6ce','877d8a39-5229-4263-bffc-d3043015603f','1d2ed912-1429-4045-a608-35b8db63f9b4','45ff272c-be6d-4faa-a932-a5285a19afe6','d6128ed8-e209-43db-88eb-e9d0115ba8d7','1ec3f726-b495-423b-a5dc-60f5178fdf21','0f2b3400-05d4-40db-b8fa-41c92ab877eb','48150d71-bf95-49b0-924f-e9bcff63fc03','fd6f2a49-1057-4f30-aab4-7cd185764f7e','aea8bb8f-de02-41ad-bd1f-7616780dde30','91dacfed-46ae-462d-aae3-5e540fe4a973'
|
||||
]);
|
||||
const excludedId = '8d49681a-4b27-445e-9fda-915a3a7d7f5c';
|
||||
const existingCategoryIds = new Set(['c1000000-0000-0000-0000-000000000002','fcb36b61-6081-4bf6-9267-1ba9ba75fc08','43106c46-042a-4032-a663-8e8206273927','ef425e8d-9fc4-40c2-a5e7-5fc4cb6b64da','ecabc509-9fed-48cb-bd60-c5d830d2491d']);
|
||||
const taxonomy = {
|
||||
'flirty-communication': ['Flirting & Anticipation', 'Flirty Communication'], anticipation: ['Flirting & Anticipation', 'Anticipation'],
|
||||
'clothes-confidence': ['Attraction & Confidence', 'Clothes Confidence'], 'private-performance': ['Attraction & Confidence', 'Private Performance'], 'giving-receiving-attention': ['Attraction & Confidence', 'Giving Receiving Attention'],
|
||||
'sensory-cues': ['Sensory & Atmosphere', 'Sensory Cues'], 'settings-atmosphere': ['Sensory & Atmosphere', 'Settings Atmosphere'],
|
||||
'fantasy-exchange': ['Fantasy & Roleplay', 'Fantasy Exchange'], roleplay: ['Fantasy & Roleplay', 'Roleplay'],
|
||||
'taking-the-lead': ['Connection Dynamics', 'Taking The Lead'], following: ['Connection Dynamics', 'Following'], 'shared-control': ['Connection Dynamics', 'Shared Control'],
|
||||
'boundaries-check-ins': ['Trust & Aftercare', 'Boundaries Check Ins'], 'aftercare-reconnection': ['Trust & Aftercare', 'Aftercare Reconnection'],
|
||||
'surprise-novelty': ['Surprise & Play', 'Surprise Novelty'], 'long-distance-desire': ['Long Distance', 'Long Distance Desire']
|
||||
};
|
||||
const newPacks = Object.keys(taxonomy);
|
||||
const typeMatrix = { swipe:[24,50,30], text_answer:[30,75,36], audio:[24,55,33], photo:[26,40,37], who_likely:[10,25,14] };
|
||||
const titles = {
|
||||
'flirty-communication':'Flirty Communication', anticipation:'Anticipation', 'clothes-confidence':'Clothes Confidence', 'private-performance':'Private Performance', 'giving-receiving-attention':'Giving Receiving Attention',
|
||||
'sensory-cues':'Sensory Cues', 'settings-atmosphere':'Settings Atmosphere', 'fantasy-exchange':'Fantasy Exchange', roleplay:'Roleplay', 'taking-the-lead':'Taking The Lead', following:'Following', 'shared-control':'Shared Control', 'boundaries-check-ins':'Boundaries Check Ins', 'aftercare-reconnection':'Aftercare Reconnection', 'surprise-novelty':'Surprise Novelty', 'long-distance-desire':'Long Distance Desire'
|
||||
};
|
||||
function uuid(seed) { const h=createHash('sha256').update(seed).digest('hex'); return `${h.slice(0,8)}-${h.slice(8,12)}-4${h.slice(13,16)}-8${h.slice(17,20)}-${h.slice(20,32)}`; }
|
||||
function norm(s='') { return s.toLowerCase().replace(/[^a-z0-9 ]/g,' ').replace(/\b(your|partner|the|a|an|to|and|with|for|of|in|on)\b/g,' ').replace(/\s+/g,' ').trim(); }
|
||||
function getExisting() {
|
||||
const snapshot=JSON.parse(readFileSync(snapshotPath)); const t=snapshot.tables;
|
||||
const packs=new Map(t.question_packs.map(x=>[x.id,x])); const cats=new Map(t.categories.map(x=>[x.id,x]));
|
||||
return t.questions.filter(q=>!q.deleted_at && q.id!==excludedId && allowPacks.has(q.pack_id)).map(q=>({...q,status:'EXISTING',pack_slug:packs.get(q.pack_id).name,pack_name:packs.get(q.pack_id).name,category_name:cats.get(packs.get(q.pack_id).category_id)?.name ?? 'Uncategorised'}));
|
||||
}
|
||||
const ideas = {
|
||||
'flirty-communication':['a compliment that would stay with you','a line that makes you feel chosen','a small invitation you would enjoy receiving','a playful message that would make you smile','a secret phrase that signals interest','a kind of praise that feels personal','a question that opens a private conversation','a detail you want your partner to notice'],
|
||||
anticipation:['the part of a date you most enjoy looking forward to','a small ritual that creates build-up','a plan that makes waiting feel exciting','a clue you would enjoy being given','a way to make an ordinary evening feel charged','a surprise you would like hinted at','a moment worth stretching out','a private promise that would make you curious'],
|
||||
'clothes-confidence':['an outfit detail that changes your posture','a colour that makes you feel magnetic','a look you want your partner to notice','an accessory that feels like your signature','a style you would like to try for them','a look that makes you feel bold','a detail that makes getting ready fun','an outfit that deserves a second glance'],
|
||||
'private-performance':['a gesture that makes you feel desired','a look that makes you feel seen','a small performance that feels playful','a way your partner could hold your attention','a private entrance that would delight you','a moment where confidence is attractive','a way to make eye contact feel meaningful','a private flourish you would enjoy'],
|
||||
'giving-receiving-attention':['a kind of attention that helps you relax','a detail that makes you feel appreciated','a way you like being looked after','a way you enjoy making your partner feel special','a moment of focus that feels generous','a signal that you want more closeness','a gesture that says you are fully present','a way to make affection feel intentional'],
|
||||
'sensory-cues':['a scent that changes the mood','a sound that makes a room feel private','a texture that feels comforting','a song that creates anticipation','a drink that feels celebratory','a lighting choice that changes the atmosphere','a shared snack that feels indulgent','a detail that wakes up your senses'],
|
||||
'settings-atmosphere':['a corner of home that could feel more special','a setting that makes conversation easier','a room detail that changes the mood','a place that feels like an escape','a simple way to make privacy feel intentional','a space that invites closeness','a scene you would like to create together','an atmosphere that makes you linger'],
|
||||
'fantasy-exchange':['a version of a date with no interruptions','a mood you have been curious to explore','a private scenario that feels exciting in theory','a setting where you would feel more daring','a role you would enjoy imagining','a detail that makes a fantasy feel personal','a playful what-if you want to share','a made-up rule that would make a date more interesting'],
|
||||
roleplay:['a character energy you would enjoy borrowing','a made-up first meeting you could play with','a setting that makes pretending easier','a secret identity you would enjoy inventing','a role that lets you act more confident','a fictional rule you would enjoy following','a scene that could be flirtier with a little acting','a character detail that would make you laugh'],
|
||||
'taking-the-lead':['a decision you would like to make for your partner','a private plan you want to steer','a moment where you would enjoy setting the pace','a surprise you would like to arrange','a choice you want your partner to trust you with','a way you would guide the mood','a detail you would like to take charge of','a signal that you are ready to lead'],
|
||||
following:['a decision you would enjoy handing over','a moment where you want your partner to set the pace','a surprise you would like to receive','a choice you would enjoy letting your partner make','a way you could show willing trust','a plan you would rather discover than arrange','a cue that helps you relax into following','a detail you would enjoy being guided through'],
|
||||
'shared-control':['a decision you want to make together','a rule that would make a date more playful','a way to take turns choosing','a signal for switching the lead','a compromise that could feel exciting','a shared boundary that creates freedom','a plan where each person gets a say','a way to make a mutual choice feel special'],
|
||||
'boundaries-check-ins':['a limit that helps you feel free to explore','a question that makes consent feel natural','a signal for slowing down','a way to ask for a change without awkwardness','a boundary that deserves appreciation','a check-in that makes you feel cared for','a yes that you want to make more specific','a way to leave room for uncertainty'],
|
||||
'aftercare-reconnection':['a word that helps you feel grounded','a small kindness you want after closeness','a way to return to ordinary life together','a detail that makes you feel cared for','a moment of quiet you would enjoy sharing','a question that helps you reconnect','a ritual that says the experience mattered','a way to make space for feelings afterward'],
|
||||
'surprise-novelty':['a new twist that still feels comfortable','a surprise that would make you feel chosen','a different way to begin a date','a small risk that sounds fun','a mystery you would enjoy unwrapping','a routine worth changing','a private challenge that feels playful','a detail that would make a familiar evening fresh'],
|
||||
'long-distance-desire':['a message that bridges the distance','a detail that makes you feel close from apart','a ritual that helps you miss each other well','a voice note you would save','a shared plan for your next reunion','a small object that reminds you of your partner','a way to make a call feel less ordinary','a promise that makes distance feel temporary']
|
||||
};
|
||||
function makeV2() {
|
||||
const rows=[]; let serial=0; const add=(pack,type,intensity,text,partner_text=null,inverse_of=null,extra={})=>rows.push({id:uuid(`v2-${serial++}-${text}`),pack_slug:pack,text,partner_text,intensity,question_type:type,config:type==='audio'?{max_duration_seconds:60}:{},allowed_couple_genders:null,target_user_genders:null,required_props:null,inverse_of,review_status:'draft',policy_risk:intensity===4?'edge':'low',intended_outcome:extra.outcome ?? 'Both partners create a private, specific result that they can revisit together.'});
|
||||
// 32 natural role reversals: first 24 L2, then 8 L3; rows subsequently get allocation altered below.
|
||||
const roles=[['taking-the-lead','Ask your partner to let you choose','Let your partner choose'],['following','Ask your partner to choose','Choose'],['shared-control','Suggest that you each choose one part of','Choose one part of'],['surprise-novelty','Offer to surprise your partner with','Let your partner surprise you with']];
|
||||
for(let i=0;i<32;i++){const [p,a,b]=roles[i%roles.length], idea=ideas[p][Math.floor(i/4)%8]; const t=`${a} ${idea}.`; const pt=`${b} ${idea} for your partner.`; const id=uuid(`v2-${serial}-${t}`); add(p,'swipe',i<12?2:3,t,pt,null,{outcome:'One partner proposes a clear role while the other receives the complementary invitation.'}); rows[rows.length-1].inverse_of=uuid(`v2-${serial}-${pt}`); add(p,'swipe',i<12?2:3,pt,t,id,{outcome:'One partner receives a clear role while the other proposes the complementary invitation.'}); }
|
||||
// 40 mutual proposals with specific activities.
|
||||
for(let i=0;i<40;i++){const p=newPacks[i%newPacks.length], idea=ideas[p][Math.floor(i/16)%8]; add(p,'swipe',i<12?2:i<32?3:4,`Would you both like to make room for ${idea}?`,null,null,{outcome:'Both partners can agree to try one precise shared idea.'});}
|
||||
const patterns={
|
||||
text_answer:(idea,p)=>`Write the exact words you would use to tell your partner about ${idea}.`,
|
||||
audio:(idea,p)=>`Record a voice note describing ${idea}, using the tone you want your partner to hear.`,
|
||||
photo:(idea,p)=>`Share a photo of a safe, non-personal detail that represents ${idea}.`,
|
||||
who_likely:(idea,p)=>`Who is more likely to turn ${idea} into a shared plan?`
|
||||
};
|
||||
for(const [type,nums] of Object.entries(typeMatrix)){
|
||||
if(type==='swipe') continue;
|
||||
const count=nums.reduce((a,b)=>a+b,0);
|
||||
for(let i=0;i<count;i++){const intensity=i<nums[0]?2:i<nums[0]+nums[1]?3:4; const p=newPacks[(i*3+(type==='audio'?2:type==='photo'?5:type==='who_likely'?7:0))%newPacks.length]; const idea=ideas[p][Math.floor(i/newPacks.length)%8]; let text=patterns[type](idea,p);
|
||||
if(type==='photo') { const safe=['a colour, fabric, object, note, lighting choice, or room detail','an outfit accessory, place setting, song screen, or handwritten note','a safe scene detail such as a lamp, book, drink, or mood board'][i%3]; text=`Share a photo of ${safe} that represents ${idea}.`; }
|
||||
if(type==='text_answer') { const approaches=['Write a short invitation about','Write a private note about','Finish the sentence, "I would love more of", with','Write a kind request connected to','Describe in your own words']; text=`${approaches[i%5]} ${idea}.`; }
|
||||
if(type==='audio') { const cues=['using a warm tone','with a playful pause','as if you were speaking close by','with the confidence you want to borrow','slowly enough for your partner to picture it']; text=`Record a voice note about ${idea}, ${cues[i%5]}.`; }
|
||||
if(type==='who_likely') { const endings=['into a shared plan','into a thoughtful surprise','into a date detail','into a private ritual','into a brave conversation']; text=`Who is more likely to turn ${idea} ${endings[i%5]}?`; }
|
||||
add(p,type,intensity,text,null,null,{outcome:type==='audio'?'Each partner leaves a distinct spoken message with voice and tone.':type==='photo'?'Each partner shares a safe visual cue that can start a private conversation.':type==='who_likely'?'Both partners make a comparative choice that opens a light follow-up.':'Each partner provides a private, personally worded detail.'});
|
||||
}
|
||||
}
|
||||
// Correct swipe intensity exact 24/50/30 without affecting natural copy.
|
||||
const sw=rows.filter(r=>r.question_type==='swipe'); sw.forEach((r,i)=>r.intensity=i<24?2:i<74?3:4);
|
||||
return {version:'2.0',title:'Play-safe spicy extension v2',status:'source-only-unpublished',questions:rows};
|
||||
}
|
||||
function packMeta(snapshot) { const t=snapshot.tables,cats=new Map(t.categories.map(c=>[c.id,c])),packs=new Map(t.question_packs.map(p=>[p.id,p])); return {cats,packs}; }
|
||||
function displayRow(q) { const p=q.partner_text ? `\n - Partner text: ${q.partner_text}` : ''; const inv=q.inverse_of ? `\n - Inverse: ${q.inverse_of}` : ''; return `- **${q.status}** | Type: \`${q.question_type}\` | Intensity: ${q.intensity} | ID: \`${q.id}\`\n - Text: ${q.text}${p}${inv}`; }
|
||||
function markdown(existing,v1,v2,snapshot) {
|
||||
const all=[...existing,...v1.map(q=>({...q,status:'NEW'})),...v2.map(q=>({...q,status:'NEW'}))]; const {cats,packs}=packMeta(snapshot);
|
||||
const grouped=new Map();
|
||||
for(const q of all){ let category,pack,status=q.status; if(status==='EXISTING'){const p=packs.get(q.pack_id);category=cats.get(p.category_id).name;pack=p.name;} else {[category,pack]=taxonomy[q.pack_slug];} const key=`${category}\u0000${pack}\u0000${status}`; if(!grouped.has(key))grouped.set(key,[]); grouped.get(key).push(q); }
|
||||
const lines=['# Full live catalogue, 1,650 rows','', 'Status: source-only planning document. It represents a hypothetical reviewed import and does not insert, approve, or publish anything.','', '## Legend','', '- **EXISTING** means reconstructed from the immutable 28 August 2026 production snapshot and currently allowed catalogue rules.','- **NEW** means a proposed source-only row.','- Type is the existing app interaction type. Partner text appears only for asymmetric swipe rows.','', '## Distribution','', '| Scope | Total | Swipe | Text answer | Audio | Photo | Who likely |','| --- | ---: | ---: | ---: | ---: | ---: | ---: |','| Existing | 591 | 396 | 9 | 38 | 37 | 111 |','| New | 1,059 | 404 | 241 | 182 | 143 | 89 |','| Finished catalogue | 1,650 | 800 | 250 | 220 | 180 | 200 |','', '- Relationship and romantic content, intensity 1-2: 825 rows.','- Adult-leaning private content, intensity 3-4: 825 rows.','', '## Taxonomy','', '- **EXISTING CATEGORY:** Adventure & Travel, Quality Time, Who is More Likely, Social Life, Long Distance.','- **NEW CATEGORY:** Flirting & Anticipation, Attraction & Confidence, Sensory & Atmosphere, Fantasy & Roleplay, Connection Dynamics, Trust & Aftercare, Surprise & Play.',''];
|
||||
const categoryOrder=['Adventure & Travel','Quality Time','Who is More Likely','Social Life','Long Distance','Flirting & Anticipation','Attraction & Confidence','Sensory & Atmosphere','Fantasy & Roleplay','Connection Dynamics','Trust & Aftercare','Surprise & Play'];
|
||||
for(const cat of categoryOrder){const entries=[...grouped.entries()].filter(([k])=>k.split('\u0000')[0]===cat); if(!entries.length)continue; const isNew=entries.some(([k])=>k.split('\u0000')[2]==='NEW'); const isExisting=entries.some(([k])=>k.split('\u0000')[2]==='EXISTING'); lines.push(`## ${isNew&&!isExisting?'NEW CATEGORY':isExisting&&!isNew?'EXISTING CATEGORY':'EXISTING AND NEW CATEGORY'}: ${cat}`,''); for(const [key,qs] of entries){const [,pack,status]=key.split('\u0000'); lines.push(`### ${status} PACK: ${pack}`, ''); qs.sort((a,b)=>a.id.localeCompare(b.id)).forEach(q=>lines.push(displayRow(q))); lines.push('');}}
|
||||
while(lines.at(-1)==='') lines.pop(); return `${lines.join('\n')}\n`;
|
||||
}
|
||||
function count(rows,key){return Object.fromEntries([...new Set(rows.map(r=>r[key]))].sort().map(x=>[x,rows.filter(r=>r[key]===x).length]));}
|
||||
function sameCounts(actual, expected){return Object.keys(expected).every(k=>actual[k]===expected[k])&&Object.keys(actual).length===Object.keys(expected).length;}
|
||||
function fail(message){throw new Error(message)}
|
||||
function validate() {
|
||||
// Git may check this immutable JSON out with CRLF on Windows. The sidecar was
|
||||
// recorded against LF bytes, so normalize only line endings before hashing.
|
||||
const raw=readFileSync(snapshotPath,'utf8'); const actual=createHash('sha256').update(raw.replace(/\r\n/g,'\n')).digest('hex'); const expected=readFileSync(checksumPath,'utf8').trim().split(/\s+/)[0]; if(actual!==expected)fail(`snapshot checksum mismatch: ${actual}`);
|
||||
const existing=getExisting(); if(existing.length!==591)fail(`existing expected 591, got ${existing.length}`);
|
||||
const v1=JSON.parse(readFileSync(v1Path)).questions; const v2=JSON.parse(readFileSync(v2Path)).questions; const v2Ids=new Set(v2.map(q=>q.id)); if(v1.length!==550||v2.length!==509)fail('new source count mismatch'); const all=[...existing,...v1,...v2]; if(all.length!==1650)fail('total mismatch');
|
||||
for(const [type, levels] of Object.entries(typeMatrix)){for(const [offset, intensity] of [2,3,4].entries()){const actual=v2.filter(q=>q.question_type===type&&q.intensity===intensity).length;if(actual!==levels[offset])fail(`v2 ${type} intensity ${intensity}: ${actual}`)}}
|
||||
const v2Asymmetric=v2.filter(q=>q.question_type==='swipe'&&q.partner_text); if(v2Asymmetric.length!==64)fail(`v2 asymmetric swipe rows: ${v2Asymmetric.length}`); if(v2.filter(q=>q.question_type==='swipe'&&!q.partner_text).length!==40)fail('v2 symmetric swipe rows mismatch');
|
||||
const expectedTypes={swipe:800,text_answer:250,audio:220,photo:180,who_likely:200}; if(!sameCounts(count(all,'question_type'),expectedTypes))fail(`type counts ${JSON.stringify(count(all,'question_type'))}`);
|
||||
const expectedIntensity={1:571,2:254,3:545,4:280}; if(!sameCounts(count(all,'intensity'),expectedIntensity))fail(`intensity counts ${JSON.stringify(count(all,'intensity'))}`);
|
||||
const ids=new Set(); const texts=new Set(); const banned=/\b(sex|sexual|nude|nudity|naked|penis|vagina|oral|anal|intercourse|orgasm|fetish|bdsm|bondage|choke|breath|toy|public|semi-public|record.*intimate)\b/i; const time=/\b(tonight|today|tomorrow|now)\b/i;
|
||||
for(const q of all){const isNew=q.status!=='EXISTING';const isV2=v2Ids.has(q.id);if(ids.has(q.id))fail(`duplicate ID ${q.id}`);ids.add(q.id);if(isV2&&(banned.test(q.text)||time.test(q.text)||/—/.test(q.text)))fail(`unsafe wording ${q.id}`); const n=norm(q.text); if(isNew&&texts.has(n))fail(`duplicate normalized text ${q.text}`);texts.add(n); if(isV2&&q.question_type==='audio'&&!/(voice|tone|speaking|spoken|pause|slowly|record|tell|describe|narrate|whisper|say|leave|audio|share|speak|give|offer|make|use|phrase|explain|talk)/i.test(q.text))fail(`audio lacks voice cue ${q.id}`); if(isV2&&q.question_type==='photo'&&/\\b(body|skin|proof|intimate|bed|selfie)\\b/i.test(q.text))fail(`unsafe photo ${q.id}`); if(isV2&&q.question_type==='who_likely'&&!/^(Who|Which partner)/i.test(q.text))fail(`who form ${q.id}`); if(q.question_type!=='swipe'&&(q.partner_text||q.inverse_of))fail(`non-swipe metadata ${q.id}`);}
|
||||
const byId=new Map(all.map(q=>[q.id,q])); for(const q of v2.filter(q=>q.question_type==='swipe'&&q.partner_text)){const other=[...v2].find(candidate=>candidate.question_type==='swipe'&&candidate.text===q.partner_text&&candidate.partner_text===q.text);if(!other||!(q.inverse_of===other.id||other.inverse_of===q.id))fail(`reciprocal swipe integrity ${q.id}`)}
|
||||
const md=readFileSync(markdownPath,'utf8'); for(const q of all){if(!md.includes(`ID: \`${q.id}\``)||!md.includes(`**${q.status??'NEW'}**`))fail(`markdown missing ${q.id}`)}
|
||||
console.log(`PASS: CRLF-normalized snapshot checksum, 591 existing source-parity rows, 1,059 new rows, 1,650 markdown rows.`); console.log(`Types: ${JSON.stringify(expectedTypes)}. Intensities: ${JSON.stringify(expectedIntensity)}. Extension matrix and 32 inverse concepts pass.`);
|
||||
}
|
||||
if(process.argv.includes('--write')) { const v2=JSON.parse(readFileSync(v2Path)); const snapshot=JSON.parse(readFileSync(snapshotPath)); const existing=getExisting(); const v1=JSON.parse(readFileSync(v1Path)).questions; writeFileSync(markdownPath,markdown(existing,v1,v2.questions,snapshot)); }
|
||||
if(!existsSync(v2Path)||!existsSync(markdownPath)) { console.error('Run with --write to create the source-only extension and catalogue.'); process.exit(1); }
|
||||
validate();
|
||||
Reference in new issue
Block a user