Skip to content

Commit 07ef91e

Browse files
InfantLabclaude
andcommitted
feat(ourmoji): username-based pairing invites
Replace the raw-user-id field on /ourmoji/experiments with an invite flow. Inviter picks a partner from a dropdown of ourmoji-enabled users, proposes dates, sends. Invitee accepts/declines in an Incoming Invitations section; on accept the experiment run is created via the existing service. - New table `ourmoji_invites` (migration 0024). - New service `app/server/services/ourmoji/invites.ts` with listOurmojiPartners, createInvite, listInvitesForViewer, acceptInvite, declineInvite, cancelInvite. - New endpoints under /api/ourmoji/: GET /partners GET /invites POST /invites POST /invites/:id/accept POST /invites/:id/decline POST /invites/:id/cancel - ExperimentRunManager.vue rewritten: partner <select>, incoming + outgoing invite sections, existing runs list preserved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ebfc8f commit 07ef91e

File tree

14 files changed

+666
-34
lines changed

14 files changed

+666
-34
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.4] - 2026-04-21
11+
12+
### Added
13+
14+
- **Ourmoji pairing invites**: replaces the raw-user-id field on the experiments page with a username-based invite flow. The inviter picks a partner from a dropdown of ourmoji-enabled users, sets dates, and sends. The invitee sees the invite in an "Incoming invitations" section and accepts or declines. On accept, the experiment run is created automatically via the existing service.
15+
- New endpoints: `GET /api/ourmoji/partners`, `GET /api/ourmoji/invites`, `POST /api/ourmoji/invites`, and `POST /api/ourmoji/invites/:id/{accept,decline,cancel}`.
16+
- New `ourmoji_invites` table (migration `0024_ourmoji_invites.sql`).
17+
1018
## [0.6.3] - 2026-04-20
1119

1220
### Added

app/components/ourmoji/ExperimentRunManager.vue

Lines changed: 164 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,134 @@
22
/**
33
* ExperimentRunManager
44
*
5-
* Minimal admin UI for Ourmoji experiment runs (US4).
6-
* Lists existing runs, lets the operator create a new one, and exposes
7-
* pause/resume controls. Statistics + assignment management land in
8-
* later phases.
5+
* UI for pairing + running Ourmoji experiments. Invite flow: pick a
6+
* partner by username, propose dates, send. The invitee sees the invite
7+
* in the "Incoming invitations" section and accepts or declines. On
8+
* accept, the underlying experiment run is created automatically.
99
*/
1010
1111
import type { OurmojiExperimentRun } from "~/types/ourmoji";
1212
13-
interface CreateForm {
13+
interface Partner {
14+
id: string;
15+
username: string;
16+
}
17+
18+
interface Invite {
19+
id: string;
20+
fromUserId: string;
21+
toUserId: string;
22+
fromUsername: string;
23+
toUsername: string;
24+
name: string;
25+
startDate: string;
26+
endDate: string;
27+
status: "pending" | "accepted" | "declined" | "cancelled";
28+
runId: string | null;
29+
createdAt: string;
30+
respondedAt: string | null;
31+
}
32+
33+
interface InviteForm {
34+
toUserId: string;
1435
name: string;
1536
startDate: string;
1637
endDate: string;
17-
participantUserIds: string; // comma-separated
1838
}
1939
2040
const runs = ref<OurmojiExperimentRun[]>([]);
41+
const partners = ref<Partner[]>([]);
42+
const incoming = ref<Invite[]>([]);
43+
const outgoing = ref<Invite[]>([]);
2144
const loading = ref(false);
2245
const error = ref<string | null>(null);
2346
24-
const form = reactive<CreateForm>({
47+
const form = reactive<InviteForm>({
48+
toUserId: "",
2549
name: "",
2650
startDate: "",
2751
endDate: "",
28-
participantUserIds: "",
2952
});
3053
3154
async function refresh() {
3255
loading.value = true;
3356
error.value = null;
3457
try {
35-
const res = await $fetch<{ runs: OurmojiExperimentRun[] }>(
36-
"/api/ourmoji/experiments",
37-
);
38-
runs.value = res.runs;
58+
const [runsRes, partnersRes, invitesRes] = await Promise.all([
59+
$fetch<{ runs: OurmojiExperimentRun[] }>("/api/ourmoji/experiments"),
60+
$fetch<{ partners: Partner[] }>("/api/ourmoji/partners"),
61+
$fetch<{ incoming: Invite[]; outgoing: Invite[] }>(
62+
"/api/ourmoji/invites",
63+
),
64+
]);
65+
runs.value = runsRes.runs;
66+
partners.value = partnersRes.partners;
67+
incoming.value = invitesRes.incoming;
68+
outgoing.value = invitesRes.outgoing;
3969
} catch (err: unknown) {
40-
error.value = err instanceof Error ? err.message : "Failed to load runs";
70+
error.value = err instanceof Error ? err.message : "Failed to load data";
4171
} finally {
4272
loading.value = false;
4373
}
4474
}
4575
46-
async function createRun() {
76+
async function sendInvite() {
4777
error.value = null;
78+
if (!form.toUserId) {
79+
error.value = "Pick a partner";
80+
return;
81+
}
4882
try {
49-
const ids = form.participantUserIds
50-
.split(",")
51-
.map((s) => s.trim())
52-
.filter(Boolean);
53-
await $fetch("/api/ourmoji/experiments", {
83+
await $fetch("/api/ourmoji/invites", {
5484
method: "POST",
5585
body: {
86+
toUserId: form.toUserId,
5687
name: form.name,
5788
startDate: form.startDate,
5889
endDate: form.endDate,
59-
participantUserIds: ids,
6090
},
6191
});
92+
form.toUserId = "";
6293
form.name = "";
6394
form.startDate = "";
6495
form.endDate = "";
65-
form.participantUserIds = "";
6696
await refresh();
6797
} catch (err: unknown) {
6898
error.value =
69-
err instanceof Error ? err.message : "Failed to create experiment";
99+
err instanceof Error ? err.message : "Failed to send invite";
100+
}
101+
}
102+
103+
async function accept(id: string) {
104+
error.value = null;
105+
try {
106+
await $fetch(`/api/ourmoji/invites/${id}/accept`, { method: "POST" });
107+
await refresh();
108+
} catch (err: unknown) {
109+
error.value =
110+
err instanceof Error ? err.message : "Failed to accept invite";
111+
}
112+
}
113+
114+
async function decline(id: string) {
115+
error.value = null;
116+
try {
117+
await $fetch(`/api/ourmoji/invites/${id}/decline`, { method: "POST" });
118+
await refresh();
119+
} catch (err: unknown) {
120+
error.value =
121+
err instanceof Error ? err.message : "Failed to decline invite";
122+
}
123+
}
124+
125+
async function cancel(id: string) {
126+
error.value = null;
127+
try {
128+
await $fetch(`/api/ourmoji/invites/${id}/cancel`, { method: "POST" });
129+
await refresh();
130+
} catch (err: unknown) {
131+
error.value =
132+
err instanceof Error ? err.message : "Failed to cancel invite";
70133
}
71134
}
72135
@@ -87,21 +150,66 @@ onMounted(() => {
87150

88151
<template>
89152
<div class="space-y-8">
153+
<div v-if="error" class="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
154+
{{ error }}
155+
</div>
156+
157+
<section v-if="incoming.length > 0" class="space-y-3">
158+
<h2 class="text-lg font-semibold">Incoming invitations</h2>
159+
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
160+
<li v-for="inv in incoming" :key="inv.id" class="flex flex-wrap items-center justify-between gap-3 py-3">
161+
<div>
162+
<div class="font-medium">
163+
From <span class="font-mono">@{{ inv.fromUsername }}</span> · {{ inv.name }}
164+
</div>
165+
<div class="text-xs text-gray-500">
166+
{{ inv.startDate }} → {{ inv.endDate }} · status: <span class="font-mono">{{ inv.status }}</span>
167+
</div>
168+
</div>
169+
<div v-if="inv.status === 'pending'" class="flex gap-2">
170+
<button
171+
type="button"
172+
class="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700"
173+
@click="accept(inv.id)"
174+
>
175+
Accept
176+
</button>
177+
<button
178+
type="button"
179+
class="rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
180+
@click="decline(inv.id)"
181+
>
182+
Decline
183+
</button>
184+
</div>
185+
</li>
186+
</ul>
187+
</section>
188+
90189
<section class="space-y-3">
91-
<h2 class="text-lg font-semibold">Create experiment run</h2>
92-
<form class="grid gap-3 sm:grid-cols-2" @submit.prevent="createRun">
190+
<h2 class="text-lg font-semibold">Invite a partner</h2>
191+
<p v-if="partners.length === 0" class="text-sm text-gray-500">
192+
No other Ourmoji-enabled users yet. When someone else has Ourmoji
193+
enabled, you'll be able to pair with them here.
194+
</p>
195+
<form v-else class="grid gap-3 sm:grid-cols-2" @submit.prevent="sendInvite">
93196
<label class="flex flex-col text-sm">
94-
<span class="mb-1 font-medium">Name</span>
95-
<input
96-
v-model="form.name"
197+
<span class="mb-1 font-medium">Partner</span>
198+
<select
199+
v-model="form.toUserId"
97200
class="rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2"
98201
required
99-
/>
202+
>
203+
<option value="" disabled>Select a user…</option>
204+
<option v-for="p in partners" :key="p.id" :value="p.id">
205+
@{{ p.username }}
206+
</option>
207+
</select>
100208
</label>
101209
<label class="flex flex-col text-sm">
102-
<span class="mb-1 font-medium">Participants (user ids, comma-separated)</span>
210+
<span class="mb-1 font-medium">Experiment name</span>
103211
<input
104-
v-model="form.participantUserIds"
212+
v-model="form.name"
105213
class="rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2"
106214
required
107215
/>
@@ -129,12 +237,37 @@ onMounted(() => {
129237
type="submit"
130238
class="rounded-md bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700"
131239
>
132-
Create run
240+
Send invitation
133241
</button>
134242
</div>
135243
</form>
136244
</section>
137245

246+
<section v-if="outgoing.length > 0" class="space-y-3">
247+
<h2 class="text-lg font-semibold">Sent invitations</h2>
248+
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
249+
<li v-for="inv in outgoing" :key="inv.id" class="flex flex-wrap items-center justify-between gap-3 py-3">
250+
<div>
251+
<div class="font-medium">
252+
To <span class="font-mono">@{{ inv.toUsername }}</span> · {{ inv.name }}
253+
</div>
254+
<div class="text-xs text-gray-500">
255+
{{ inv.startDate }} → {{ inv.endDate }} · status: <span class="font-mono">{{ inv.status }}</span>
256+
</div>
257+
</div>
258+
<div v-if="inv.status === 'pending'" class="flex gap-2">
259+
<button
260+
type="button"
261+
class="rounded-md border border-gray-300 px-3 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
262+
@click="cancel(inv.id)"
263+
>
264+
Cancel
265+
</button>
266+
</div>
267+
</li>
268+
</ul>
269+
</section>
270+
138271
<section class="space-y-3">
139272
<header class="flex items-center justify-between">
140273
<h2 class="text-lg font-semibold">Runs</h2>
@@ -147,7 +280,6 @@ onMounted(() => {
147280
</button>
148281
</header>
149282

150-
<div v-if="error" class="text-sm text-red-600">{{ error }}</div>
151283
<div v-if="loading && runs.length === 0" class="text-sm text-gray-500">
152284
Loading…
153285
</div>

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tada",
3-
"version": "0.6.3",
3+
"version": "0.6.4",
44
"private": true,
55
"type": "module",
66
"description": "Personal lifelogger - Track Activities, Discover Achievements",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* GET /api/ourmoji/invites
3+
*
4+
* List the viewer's incoming and outgoing pairing invites.
5+
*/
6+
7+
import { defineEventHandler, createError } from "h3";
8+
9+
import { unauthorized } from "~/server/utils/response";
10+
import { isOurmojiEnabledForUser } from "~/server/services/ourmoji/access";
11+
import { listInvitesForViewer } from "~/server/services/ourmoji/invites";
12+
13+
export default defineEventHandler(async (event) => {
14+
const user = event.context.user;
15+
if (!user) throw createError(unauthorized(event));
16+
17+
if (!(await isOurmojiEnabledForUser(user.id))) {
18+
throw createError({ statusCode: 404, statusMessage: "Not found" });
19+
}
20+
21+
const { incoming, outgoing } = await listInvitesForViewer(user.id);
22+
return { incoming, outgoing };
23+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* POST /api/ourmoji/invites
3+
*
4+
* Create a pending pairing invite from the viewer to another ourmoji-
5+
* enabled user. On accept, an experiment run is created.
6+
*/
7+
8+
import { defineEventHandler, readBody, createError } from "h3";
9+
10+
import { unauthorized } from "~/server/utils/response";
11+
import { isOurmojiEnabledForUser } from "~/server/services/ourmoji/access";
12+
import { createInvite } from "~/server/services/ourmoji/invites";
13+
import { parseOrThrow } from "~/server/services/ourmoji/validation";
14+
import { createInviteSchema } from "./schemas";
15+
16+
export default defineEventHandler(async (event) => {
17+
const user = event.context.user;
18+
if (!user) throw createError(unauthorized(event));
19+
20+
if (!(await isOurmojiEnabledForUser(user.id))) {
21+
throw createError({ statusCode: 404, statusMessage: "Not found" });
22+
}
23+
24+
const body = await readBody(event);
25+
const payload = parseOrThrow(createInviteSchema, body, "invite payload");
26+
27+
const invite = await createInvite({
28+
fromUserId: user.id,
29+
toUserId: payload.toUserId,
30+
name: payload.name,
31+
startDate: payload.startDate,
32+
endDate: payload.endDate,
33+
});
34+
35+
return { invite };
36+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* POST /api/ourmoji/invites/:id/accept
3+
*
4+
* Accept a pending pairing invite. Creates the underlying experiment run.
5+
*/
6+
7+
import { defineEventHandler, createError, getRouterParam } from "h3";
8+
9+
import { unauthorized } from "~/server/utils/response";
10+
import { isOurmojiEnabledForUser } from "~/server/services/ourmoji/access";
11+
import { acceptInvite } from "~/server/services/ourmoji/invites";
12+
13+
export default defineEventHandler(async (event) => {
14+
const user = event.context.user;
15+
if (!user) throw createError(unauthorized(event));
16+
17+
if (!(await isOurmojiEnabledForUser(user.id))) {
18+
throw createError({ statusCode: 404, statusMessage: "Not found" });
19+
}
20+
21+
const id = getRouterParam(event, "id");
22+
if (!id) {
23+
throw createError({ statusCode: 400, statusMessage: "Missing invite id" });
24+
}
25+
26+
const result = await acceptInvite(id, user.id);
27+
return result;
28+
});

0 commit comments

Comments
 (0)