All files / web/src/lib session-share.ts

48.83% Statements 105/215
100% Branches 11/11
30% Functions 3/10
48.83% Lines 105/215

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 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 2162x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 1202x 1202x 1202x 1202x 1202x 12020x 12020x 1202x 1202x 1202x 2x 2x 2x 2x 2x 114x 109x 114x 1080x 5x 5x 1080x 104x 104x 104x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 5x 5x 5x 2x 5x 3x 5x 5x 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                            
import { and, eq } from 'drizzle-orm'
import { db } from '@/db'
import {
  sessionObservationShares,
  type NewSessionObservationShare,
  type SessionObservationShare,
} from '@/db/schema'
 
// ============================================================================
// Token Generation
// ============================================================================
 
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const TOKEN_LENGTH = 10 // ~59 bits of entropy
 
/**
 * Generate a cryptographically random 10-char base62 token
 */
export function generateShareToken(): string {
  let result = ''
  const randomBytes = new Uint8Array(TOKEN_LENGTH)
  crypto.getRandomValues(randomBytes)
 
  for (let i = 0; i < TOKEN_LENGTH; i++) {
    result += BASE62_CHARS[randomBytes[i] % BASE62_CHARS.length]
  }
 
  return result
}
 
/**
 * Check if a token has valid format
 */
export function isValidShareToken(token: string): boolean {
  if (token.length !== TOKEN_LENGTH) return false
 
  for (let i = 0; i < token.length; i++) {
    if (!BASE62_CHARS.includes(token[i])) {
      return false
    }
  }
 
  return true
}
 
// ============================================================================
// Expiration Helpers
// ============================================================================
 
export type ShareDuration = '1h' | '24h'
 
/**
 * Calculate expiration timestamp from duration
 */
export function getExpirationTime(duration: ShareDuration): Date {
  const now = new Date()
  switch (duration) {
    case '1h':
      return new Date(now.getTime() + 60 * 60 * 1000)
    case '24h':
      return new Date(now.getTime() + 24 * 60 * 60 * 1000)
  }
}
 
// ============================================================================
// CRUD Operations
// ============================================================================
 
/**
 * Create a new session share link
 */
export async function createSessionShare(
  sessionId: string,
  playerId: string,
  createdBy: string,
  duration: ShareDuration
): Promise<SessionObservationShare> {
  const token = generateShareToken()
  const expiresAt = getExpirationTime(duration)

  const newShare: NewSessionObservationShare = {
    id: token,
    sessionId,
    playerId,
    createdBy,
    expiresAt,
    status: 'active',
    viewCount: 0,
  }

  await db.insert(sessionObservationShares).values(newShare)

  return {
    ...newShare,
    createdAt: new Date(),
    lastViewedAt: null,
  } as SessionObservationShare
}
 
/**
 * Get a session share by token
 */
export async function getSessionShare(token: string): Promise<SessionObservationShare | null> {
  if (!isValidShareToken(token)) {
    return null
  }

  const shares = await db
    .select()
    .from(sessionObservationShares)
    .where(eq(sessionObservationShares.id, token))
    .limit(1)

  return shares[0] || null
}
 
/**
 * Validation result for a share token
 */
export interface ShareValidation {
  valid: boolean
  error?: string
  share?: SessionObservationShare
}
 
/**
 * Validate a session share token
 * Checks: exists, not expired, not revoked, session still active
 */
export async function validateSessionShare(token: string): Promise<ShareValidation> {
  const share = await getSessionShare(token)

  if (!share) {
    return { valid: false, error: 'Share link not found' }
  }

  if (share.status === 'revoked') {
    return { valid: false, error: 'Share link has been revoked' }
  }

  if (share.status === 'expired') {
    return { valid: false, error: 'Share link has expired' }
  }

  // Check time-based expiration
  if (new Date() > share.expiresAt) {
    // Mark as expired in database
    await db
      .update(sessionObservationShares)
      .set({ status: 'expired' })
      .where(eq(sessionObservationShares.id, token))

    return { valid: false, error: 'Share link has expired' }
  }

  return { valid: true, share }
}
 
/**
 * Increment the view count for a share
 */
export async function incrementShareViewCount(token: string): Promise<void> {
  const share = await getSessionShare(token)
  if (!share) return

  await db
    .update(sessionObservationShares)
    .set({
      viewCount: share.viewCount + 1,
      lastViewedAt: new Date(),
    })
    .where(eq(sessionObservationShares.id, token))
}
 
/**
 * Revoke a specific share link
 */
export async function revokeSessionShare(token: string): Promise<void> {
  await db
    .update(sessionObservationShares)
    .set({ status: 'revoked' })
    .where(eq(sessionObservationShares.id, token))
}
 
/**
 * Revoke all active shares for a session (called when session ends)
 */
export async function revokeSharesForSession(sessionId: string): Promise<void> {
  await db
    .update(sessionObservationShares)
    .set({ status: 'expired' })
    .where(
      and(
        eq(sessionObservationShares.sessionId, sessionId),
        eq(sessionObservationShares.status, 'active')
      )
    )
}
 
/**
 * Get all active shares for a session
 */
export async function getActiveSharesForSession(
  sessionId: string
): Promise<SessionObservationShare[]> {
  return db
    .select()
    .from(sessionObservationShares)
    .where(
      and(
        eq(sessionObservationShares.sessionId, sessionId),
        eq(sessionObservationShares.status, 'active')
      )
    )
}