Skip to main content

Approval Algorithm

Planned module

The like-group / approval module is not yet shipped on the new stack. The algorithm itself is finalized; the implementation snippets below show how it will be expressed in Hono + Drizzle.

The approval algorithm determines when a like group is approved to access an event's private chat.

Overview

A = Number of event authors
G = Number of group members

Group is approved when: approvals >= min(A, G)

The Formula

tip

Key Insight: Each group member needs at least one author approval, but we use the minimum of authors and members to avoid requiring more approvals than members exist.

Approval Threshold

approval_threshold = min(authors_count, group_members_count)

Required Approvals

approved = (approvals_received >= approval_threshold)

Examples

Authors (A)Members (G)ThresholdRequired Approvals
11min(1,1)=11
31min(3,1)=11
33min(3,3)=33
23min(2,3)=22
510min(5,10)=55

State Machine

State Values

StateDescriptionChat Created
pendingAwaiting author approval❌ No
approvedApproved by enough authors✅ Yes
rejectedRejected by any author❌ No
expiredTimed out (24h)❌ No

Implementation

The check runs inside the Drizzle transaction that records the approval. Pseudocode for src/feats/groups/groups.service.ts:

import { db } from "@infra/db/drizzle.client";
import {
likeGroupApprovalsTable,
likeGroupMembersTable,
likeGroupsTable,
eventAuthorsTable,
chatsTable,
chatParticipantsTable,
} from "@infra/db/schema";

export async function approveGroup(authorId: string, groupId: string) {
return db.transaction(async (tx) => {
// 1. Record the approval (idempotent on (group_id, author_id) unique).
await tx
.insert(likeGroupApprovalsTable)
.values({ groupId, authorId, approved: true })
.onConflictDoNothing();

// 2. Recompute counts under the same snapshot.
const [{ approvals, members, authors }] = await tx.execute(/* sql */ `
SELECT
(SELECT count(*) FROM like_group_approvals
WHERE group_id = ${groupId} AND approved = true)::int AS approvals,
(SELECT count(*) FROM like_group_members
WHERE group_id = ${groupId})::int AS members,
(SELECT count(*) FROM event_authors a
JOIN like_groups g ON g.event_id = a.event_id
WHERE g.id = ${groupId})::int AS authors
`);

const threshold = Math.min(authors, members);
if (approvals < threshold) return { approved: false } as const;

// 3. Create the chat + add participants.
const [chat] = await tx
.insert(chatsTable)
.values({ type: "approved_group" })
.returning();

const memberRows = await tx
.select({ userId: likeGroupMembersTable.userId })
.from(likeGroupMembersTable)
.where(/* group_id = groupId */);

const authorRows = await tx
.select({ userId: eventAuthorsTable.userId })
.from(eventAuthorsTable)
.innerJoin(/* join via like_groups.event_id */);

await tx.insert(chatParticipantsTable).values(
[...memberRows, ...authorRows].map((p) => ({
chatId: chat.id,
userId: p.userId,
})),
);

// 4. Flip group status.
await tx
.update(likeGroupsTable)
.set({ status: "approved", chatId: chat.id })
.where(/* id = groupId AND status = 'pending' */);

return { approved: true, chatId: chat.id } as const;
});
}

Notes:

  • The whole sequence is one Drizzle transaction so pending → approved and chat creation are atomic.

  • onConflictDoNothing() makes the approval insert idempotent against the (group_id, author_id) unique index — see the model.

  • Only the author-role check is done in code; we do not RLS-gate this because the API runs as service_role. The check is:

    const isAuthor = await tx
    .select({ ok: sql<number>`1` })
    .from(eventAuthorsTable)
    .innerJoin(/* like_groups → events */)
    .where(/* event_id = group.event_id AND user_id = authorId */)
    .limit(1);
    if (!isAuthor.length) throw new ForbiddenHTTPException();

Edge Cases

1. Group Shrinks Before Approval

If group creator removes members before approval:

  • Recalculate threshold with new member count
  • Previously submitted approvals still count

2. Author Removed

If event author is removed:

  • Recalculate threshold
  • Existing approvals may need recount

3. Multiple Authors Approve Same Member

Only one approval per author counts, even if they approve multiple members:

  • approvals >= threshold not unique_members_approved >= threshold

4. Concurrent Approvals

If multiple authors approve simultaneously:

  • All approvals count
  • First to reach threshold triggers chat creation
  • Others are ignored but recorded for audit

Side Effects

When approved:

  1. Group statusapproved
  2. Chat created → New chats row of type approved_group
  3. Users added → All group members + event authors as chat_participants
  4. Notification sentnotifications row of type group_approved, fanned out to clients via Supabase Realtime

Security Considerations

  • Only event authors can approve/reject
  • Approval is per-group, not per-member
  • Rejection is immediate (any author can reject)
  • Once approved, cannot be undone

API Endpoints

MethodEndpointDescription
POST/groups/:id/approveApprove a group
POST/groups/:id/rejectReject a group
GET/groups/:id/approvalsGet approval status