All files / web/src/db/schema song-shares.ts

100% Statements 105/105
100% Branches 4/4
100% Functions 2/2
100% Lines 105/105

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 1062x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 39x 39x 39x 39x 2x 2x 2x 37x 37x 37x 37x 37x 37x 37x 37x 2x 2x 2x 2x  
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { relations } from 'drizzle-orm'
import { sessionSongs } from './session-songs'
import { players } from './players'
 
/**
 * Song shares table — permanent, revocable public links to a celebration song.
 *
 * A parent generates a share for a completed song; anyone with the short code
 * can view a public keepsake page (no login). What the page reveals about the
 * child is opt-in per share via the `visibility` toggles — the default exposes
 * only first name + emoji, the song, its musical style, and the lyrics.
 *
 * Modeled on `worksheet_shares` (stable short base62 code, view counter) rather
 * than the time-limited `session_observation_shares`.
 */
 
/**
 * Per-share visibility + playback toggles. Privacy toggles (the `show*` keys)
 * default to false; `autoPlay` is a UX preference that defaults to true so the
 * keepsake link feels alive when opened. Browsers may still block autoplay —
 * the player surfaces a tappable button when that happens.
 */
export interface SongShareVisibility {
  /** Reveal the child's age */
  showAge: boolean
  /** Reveal session accuracy / score */
  showAccuracy: boolean
  /** Reveal specific problem detail (the comeback problem, etc.) */
  showProblemDetail: boolean
  /** Reveal best streak and skill spotlights */
  showStreakSkills: boolean
  /** Attempt to autoplay when the public page opens (default true). */
  autoPlay: boolean
}
 
export const DEFAULT_SONG_SHARE_VISIBILITY: SongShareVisibility = {
  showAge: false,
  showAccuracy: false,
  showProblemDetail: false,
  showStreakSkills: false,
  autoPlay: true,
}
 
export const songShares = sqliteTable(
  'song_shares',
  {
    /** Short base62 code via generateShareId() — clean URL /song/{id} */
    id: text('id').primaryKey(),
 
    /** The song this link shares */
    songId: text('song_id')
      .notNull()
      .references(() => sessionSongs.id, { onDelete: 'cascade' }),
 
    /** Player the song was made for (denormalized for owner lookup / revoke-all) */
    playerId: text('player_id')
      .notNull()
      .references(() => players.id, { onDelete: 'cascade' }),
 
    /** User who created the share (must be a parent of the player) */
    createdBy: text('created_by').notNull(),
 
    /** JSON SongShareVisibility — opt-in toggles, all false by default */
    visibility: text('visibility', { mode: 'json' })
      .$type<SongShareVisibility>()
      .notNull()
      .$defaultFn(() => DEFAULT_SONG_SHARE_VISIBILITY),
 
    /** active: link works; revoked: link 404s (manual revoke) */
    status: text('status', { enum: ['active', 'revoked'] })
      .notNull()
      .default('active'),
 
    /** View counter — incremented on each public page load */
    views: integer('views').notNull().default(0),
 
    /** Last time the public page was loaded */
    lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }),
 
    /** When the share was created */
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (table) => ({
    songIdx: index('song_shares_song_id_idx').on(table.songId),
    playerIdx: index('song_shares_player_id_idx').on(table.playerId),
    statusIdx: index('song_shares_status_idx').on(table.status),
  })
)
 
export const songSharesRelations = relations(songShares, ({ one }) => ({
  song: one(sessionSongs, {
    fields: [songShares.songId],
    references: [sessionSongs.id],
  }),
  player: one(players, {
    fields: [songShares.playerId],
    references: [players.id],
  }),
}))
 
export type SongShare = typeof songShares.$inferSelect
export type NewSongShare = typeof songShares.$inferInsert