Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
Stone Maps

Teams and Campaigns: Scoped Journaling for Groups

#stonemaps#devlog#feature#build

Teams and Campaigns: Scoped Journaling for Groups

Stone Maps is primarily a private journal. You and your stone, your place-memories, your Emissary. That's the core.

But there's a social layer. Teams let multiple people journal toward a shared context. Campaigns let teams scope their journaling to a specific goal or time period. The map shows team posts filtered by membership. It's still slow, still sparse — but it's not only solitary.

The Team Model

A team is simple at the schema level:

export const teams = pgTable('teams', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: text('name').notNull(),
  description: text('description'),
  goal: text('goal'),
  ownerUserId: uuid('owner_user_id').references(() => users.id).notNull(),
  inviteCode: text('invite_code').unique(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

inviteCode is the join mechanism — a short, unique token. Share the link, someone uses it to join. No email invitations, no OAuth callbacks, no invitation expiry system. For early access, this is enough.

The interesting part is the membership table:

export const teamMemberships = pgTable('team_memberships', {
  teamId: uuid('team_id').references(() => teams.id, { onDelete: 'cascade' }).notNull(),
  userId: uuid('user_id').references(() => users.id).notNull(),
  selfPairId: uuid('self_pair_id').references(() => selfPairs.id).notNull(),
  role: text('role', { enum: teamRoles }).notNull().default('member'),
  invitedBy: uuid('invited_by').references(() => users.id),
  ...
});

selfPairId on the membership — not just userId — means a team membership is scoped to a specific stone pairing. When you post to a team, it's not just "you" posting, it's "you as your stone" posting. The team is a collection of stone-pairs, not a collection of accounts.

This means a user who has paired with two stones (unlikely in early access, but architecturally possible) could be a member of a team with stone A but not stone B. The stone isn't incidental — it's the identity.

The Permission Hierarchy

Four roles: owner, admin, member, viewer. Each maps to a set of boolean permissions:

export function getPermissions(role: TeamRoleType): Permission {
  switch (role) {
    case 'owner':
      return { canManageTeam: true, canManageMembers: true, canCreateCampaigns: true,
               canEditCampaigns: true, canCreatePosts: true, canViewPosts: true,
               canViewMembers: true, canManageChannels: true };
    case 'admin':
      return { canManageTeam: false, canManageMembers: true, canCreateCampaigns: true,
               canEditCampaigns: true, canCreatePosts: true, canViewPosts: true,
               canViewMembers: true, canManageChannels: true };
    case 'member':
      return { canManageTeam: false, canManageMembers: false, canCreateCampaigns: false,
               canEditCampaigns: false, canCreatePosts: true, canViewPosts: true,
               canViewMembers: true, canManageChannels: false };
    case 'viewer':
      return { canManageTeam: false, canManageMembers: false, canCreateCampaigns: false,
               canEditCampaigns: false, canCreatePosts: false, canViewPosts: true,
               canViewMembers: true, canManageChannels: false };
  }
}

The one nuance worth explaining: admin cannot canManageTeam. That flag is for destructive team-level actions — deleting the team, transferring ownership — which only the owner can do. Admins can do everything else: manage members, create campaigns, manage channels. But they can't hand the team to someone else or shut it down.

API routes check permissions with canPerformAction(role, action). There's also hasMinimumRole() for when you want to check "at least member" rather than a specific permission — useful for endpoints that just need "are you part of this team at all."

Campaigns

Campaigns are scoped journaling events within a team. They have a status lifecycle — draft → live → closed — and structured goal and milestone tracking:

export const campaigns = pgTable('campaigns', {
  teamId: uuid('team_id').references(() => teams.id).notNull(),
  name: text('name').notNull(),
  status: text('status', { enum: ['draft', 'live', 'closed'] }).default('draft').notNull(),
  goal: jsonb('goal').$type<{ type?: string; target?: number; unit?: string }>(),
  milestones: jsonb('milestones').$type<Array<{
    id: string; name: string; target: number; reached?: boolean; reachedAt?: string;
  }>>(),
  startDate: timestamp('start_date').notNull(),
  endDate: timestamp('end_date'),
  progress: jsonb('progress').$type<{ current?: number; percentage?: number; lastUpdated?: string }>(),
});

Goals and milestones are open JSONB — not a rigid schema. A campaign could be "walk 100km and post from each significant stop" or "document every tree species in this park over the summer" or "journal from the same spot for 30 days." The goal structure can accommodate all of these without a new migration.

Progress tracking (progress.current, progress.percentage) is updated when posts are created against the campaign. The exact logic for what counts as progress depends on the campaign goal type — something we haven't fully formalized yet. Right now the fields exist and can be updated; the automation is partially wired.

Posts with Team Scope

Posts have both teamId and campaignId columns. A post can be:

  • Personal journal only (no team, no campaign)
  • Team-scoped (visible to team members)
  • Campaign-scoped (visible to team members, linked to a specific campaign)

Visibility is a separate field: private, team, pair, or public. A team-scoped post with visibility: 'team' is only visible to people in that team. A campaign post can still be private — the campaign ID is a categorization, not a visibility override.

The map endpoint supports filtering by teamId and campaignId simultaneously. When you're viewing a team campaign on the map, you see only that campaign's posts within the current bounding box. Members of the team see each other's journals through that lens.

What Teams Are For

The most interesting use case we've imagined — but haven't shipped to real users yet — is institutional deployment. A university field ecology class, each student paired with a stone, all members of a team, journaling from field sites over a semester. A campaign scoped to the semester. The map shows where everyone has been and what they've noticed. The Emissary conversations are still private; the map posts are shared.

The team doesn't collapse individual experience into a collective summary. It makes individual experiences visible to each other. That's the design intent: not a group chat, not a shared document, but parallel private journals that can see each other on a map.

Whether that's useful in practice is something only early-access groups will tell us.