Service layer documentation for Study Together bot. This guide covers all business logic services that interact with Firebase Firestore and provide functionality to the Discord bot.
Manages session lifecycle (create, pause, resume, complete, cancel).
constructor(db: Firestore)Parameters:
db- Firebase Firestore instance
Get a user's active session.
Parameters:
userId- Discord user ID
Returns:
ActiveSessionobject if session existsnullif no active session
Example:
const session = await sessionService.getActiveSession(userId);
if (session) {
console.log(`Session started at ${session.startTime}`);
}Get all active sessions across all servers.
Returns:
- Array of
ActiveSessionobjects - Empty array if no active sessions
Example:
const allSessions = await sessionService.getAllActiveSessions();
console.log(`${allSessions.length} users currently studying`);Get all active sessions for a specific server.
Parameters:
serverId- Discord guild/server ID
Returns:
- Array of
ActiveSessionobjects for that server - Empty array if no active sessions
Example:
const serverSessions = await sessionService.getActiveSessionsByServer(guildId);
// Display "who's studying" listcreateActiveSession(userId: string, username: string, serverId: string, activity: string): Promise<void>
Create a new active session for a user.
Parameters:
userId- Discord user IDusername- Discord usernameserverId- Discord server IDactivity- What the user is working on
Throws:
- Error if user already has an active session
Example:
await sessionService.createActiveSession(
userId,
username,
guildId,
'Learning React hooks'
);Firestore write:
discord-data/activeSessions/sessions/{userId}
{
userId: string,
username: string,
serverId: string,
activity: string,
startTime: Timestamp,
isPaused: false,
pausedDuration: 0
}
Update fields of an active session.
Parameters:
userId- Discord user IDupdates- Partial object with fields to update
Example:
// Pause a session
await sessionService.updateActiveSession(userId, {
isPaused: true,
pausedAt: Timestamp.now()
});Pause a user's active session.
Parameters:
userId- Discord user ID
Throws:
- Error if no active session
- Error if session already paused
Example:
await sessionService.pauseSession(userId);Updates:
isPaused: truepausedAt: Timestamp.now()
Resume a paused session.
Parameters:
userId- Discord user ID
Throws:
- Error if no active session
- Error if session not paused
Example:
await sessionService.unpauseSession(userId);Updates:
- Calculates paused duration
- Adds to
pausedDurationtotal - Sets
isPaused: false - Removes
pausedAtfield
Complete an active session and save it.
Parameters:
userId- Discord user IDtitle- Session titledescription- What was accomplished
Returns:
CompletedSessionobject with calculated duration and XP
Throws:
- Error if no active session
Example:
const completed = await sessionService.completeSession(
userId,
'Built Discord Bot',
'Implemented session tracking and Firebase integration'
);
console.log(`Duration: ${completed.duration}s`);
console.log(`XP: ${completed.xpGained}`);What happens:
- Fetches active session
- Calculates total duration (minus paused time)
- Calculates XP (via
XPService.calculateSessionXP()) - Creates
CompletedSessiondocument - Deletes active session
- Returns completed session data
Firestore writes:
- Creates:
discord-data/sessions/completed/{sessionId} - Deletes:
discord-data/activeSessions/sessions/{userId}
Cancel an active session without saving.
Parameters:
userId- Discord user ID
Throws:
- Error if no active session
Example:
await sessionService.cancelSession(userId);Firestore write:
- Deletes:
discord-data/activeSessions/sessions/{userId}
Get a user's completed sessions.
Parameters:
userId- Discord user IDlimit(optional) - Maximum number to return (default: 10)
Returns:
- Array of
CompletedSessionobjects, sorted bycreatedAtdescending
Example:
const recentSessions = await sessionService.getCompletedSessionsByUser(userId, 5);
// Display last 5 sessionsManages user statistics, streaks, and leaderboards.
constructor(db: Firestore)Get user statistics.
Parameters:
userId- Discord user ID
Returns:
UserStatsobject if user has statsnullif user has never completed a session
Example:
const stats = await statsService.getUserStats(userId);
if (stats) {
console.log(`Total hours: ${stats.totalDuration / 3600}`);
console.log(`Streak: ${stats.currentStreak} days`);
}Get user stats or create if they don't exist.
Parameters:
userId- Discord user IDusername- Discord username
Returns:
UserStatsobject (existing or newly created)
Example:
const stats = await statsService.getOrCreateUserStats(userId, username);
// Always returns a stats objectFirestore write (if creating):
discord-data/userStats/stats/{userId}
{
username: string,
totalSessions: 0,
totalDuration: 0,
currentStreak: 0,
longestStreak: 0,
lastSessionAt: Timestamp.now(),
firstSessionAt: Timestamp.now(),
xp: 0,
achievements: []
}
Update user stats after completing a session.
Parameters:
userId- Discord user IDsessionData- Completed session object
Example:
await statsService.updateUserStats(userId, completedSession);What updates:
- Increments
totalSessions - Adds to
totalDuration - Adds to
xp - Updates
lastSessionAt - Recalculates
currentStreakandlongestStreak - Updates
sessionsByDaymap - Updates time-of-day tracking fields
Streak calculation:
- If session is today: Keep current streak
- If session is yesterday: Increment streak
- If session is older: Reset streak to 1
getLeaderboard(serverId: string, timeframe: 'daily' | 'weekly' | 'monthly' | 'all'): Promise<LeaderboardEntry[]>
Get server leaderboard for a timeframe.
Parameters:
serverId- Discord server IDtimeframe- Time period to rank by
Returns:
- Array of
LeaderboardEntryobjects with:userIdusernamexp(for timeframe)leveltotalHours(for timeframe)rank
Example:
const leaderboard = await statsService.getLeaderboard(guildId, 'weekly');
leaderboard.forEach((entry, index) => {
console.log(`#${index + 1} ${entry.username}: ${entry.xp} XP`);
});Calculation:
daily: Sessions from today (00:00 - 23:59)weekly: Sessions from current week (Monday - Sunday)monthly: Sessions from current monthall: All-time totals
Calculate user's current streak considering sessions AND goals.
Parameters:
userId- Discord user ID
Returns:
- Object with
currentStreakandlongestStreak
Example:
const { currentStreak, longestStreak } = await statsService.getCurrentStreak(userId);
console.log(`Current: ${currentStreak}, Best: ${longestStreak}`);Note: This method checks both completed sessions and daily goals to determine streaks.
Manages XP calculations, awarding, and level progression.
constructor(db: Firestore)awardXP(userId: string, amount: number, reason: string): Promise<{ newXp: number; newLevel: number; leveledUp: boolean; levelsGained: number }>
Award XP to a user and check for level ups.
Parameters:
userId- Discord user IDamount- XP to awardreason- Reason for logging
Returns:
- Object with:
newXp- User's total XP after awardnewLevel- User's current levelleveledUp- Whether user leveled uplevelsGained- Number of levels gained (can be > 1)
Throws:
- Error if user stats not found
Example:
const result = await xpService.awardXP(userId, 150, 'Session completed');
if (result.leveledUp) {
console.log(`Level up! Now level ${result.newLevel}`);
// Show level up card
}Calculate base XP from session duration.
Formula: 100 XP per hour (rounded up)
Parameters:
durationSeconds- Session duration in seconds
Returns:
- XP earned (integer)
Examples:
xpService.calculateSessionXP(3600); // 1 hour = 100 XP
xpService.calculateSessionXP(1800); // 30 min = 50 XP
xpService.calculateSessionXP(7200); // 2 hours = 200 XP
xpService.calculateSessionXP(60); // 1 min = 2 XPgetSessionXPBreakdown(durationSeconds: number, isStreakMilestone: boolean, streakDays: number): { total: number; base: number; bonuses: { name: string; amount: number }[] }
Get detailed XP breakdown with bonuses.
Parameters:
durationSeconds- Session duration in secondsisStreakMilestone- Whether this session hits a streak milestonestreakDays- Current streak length
Returns:
- Object with:
total- Total XP (base + bonuses)base- Base XP from timebonuses- Array of bonus objects with name and amount
Example:
const breakdown = xpService.getSessionXPBreakdown(7200, true, 7);
// {
// total: 250,
// base: 200,
// bonuses: [
// { name: '7-day streak bonus', amount: 50 }
// ]
// }Bonuses:
- 7-day streak: +50 XP
- 30-day streak: +100 XP
- 100-day streak: +250 XP
- First session of day: +20 XP
Manages achievement unlocking and tracking.
constructor(db: Firestore)Check all achievements and unlock eligible ones.
Parameters:
userId- Discord user IDstats- User's current stats
Returns:
- Array of newly unlocked achievement IDs
Example:
const unlocked = await achievementService.checkAndUnlockAchievements(userId, stats);
if (unlocked.length > 0) {
console.log(`Unlocked: ${unlocked.join(', ')}`);
// Show achievement unlock cards
}What happens:
- Gets all achievement definitions
- Gets user's current unlocked achievements
- Checks conditions for each locked achievement
- Unlocks eligible achievements
- Awards bonus XP for each unlock
- Returns array of newly unlocked IDs
Manually unlock a specific achievement.
Parameters:
userId- Discord user IDachievementId- Achievement ID to unlock
Example:
await achievementService.unlockAchievement(userId, 'first_steps');Firestore write:
- Adds achievement ID to
achievementsarray - Adds unlock timestamp to
achievementsUnlockedAtmap - Awards bonus XP (via
XPService)
Get all unlocked achievements for a user.
Parameters:
userId- Discord user ID
Returns:
- Array of unlocked achievement IDs
- Empty array if user has no unlocked achievements
Example:
const achievements = await achievementService.getUserAchievements(userId);
console.log(`${achievements.length} achievements unlocked`);getProgress(userId: string, achievementId: string): Promise<{ current: number; required: number; percentage: number }>
Get progress toward an achievement.
Parameters:
userId- Discord user IDachievementId- Achievement ID
Returns:
- Object with:
current- Current progress valuerequired- Required value to unlockpercentage- Progress percentage (0-100)
Example:
const progress = await achievementService.getProgress(userId, 'centurion');
console.log(`${progress.current}/${progress.required} hours (${progress.percentage}%)`);Manages study groups, memberships, and group stats.
constructor(db: Firestore)createGroup(ownerId: string, ownerUsername: string, serverId: string, groupName: string, isPublic: boolean): Promise<string>
Create a new study group.
Parameters:
ownerId- Discord user ID of creatorownerUsername- Creator's usernameserverId- Discord server IDgroupName- Group name (max 50 chars)isPublic- Whether group appears in/findgroups
Returns:
- Group ID (e.g., "GP-A1B2")
Throws:
- Error if user already in a group
- Error if group name already exists
Example:
const groupId = await groupService.createGroup(
userId,
username,
guildId,
'Study Warriors',
true
);
console.log(`Group created: ${groupId}`);Firestore writes:
- Creates:
discord-data/groups/active/{groupId} - Creates:
discord-data/groupMembers/memberships/{ownerId}
Join an existing group.
Parameters:
userId- Discord user IDusername- User's usernamegroupId- Group ID to join
Throws:
- Error if user already in a group
- Error if group full (5/5 members)
- Error if group doesn't exist
Example:
await groupService.joinGroup(userId, username, 'GP-A1B2');Leave current group.
Parameters:
userId- Discord user ID
Throws:
- Error if user not in a group
Example:
await groupService.leaveGroup(userId);What happens:
- Removes user from group's
membersarray - Deletes user's
groupMembersdocument - If user was owner and last member: Deletes group
- If user was owner but others remain: Transfers ownership to oldest member
Get detailed group information.
Parameters:
groupId- Group ID
Returns:
GroupOverviewobject with:- Group details (name, level, XP, etc.)
- Member list with stats
- Recent activity
Example:
const overview = await groupService.getGroupOverview('GP-A1B2');
console.log(`${overview.groupName} - Level ${overview.groupLevel}`);Update group stats after a member completes a session.
Parameters:
groupId- Group IDduration- Session duration in secondsxp- XP earned from session
Example:
await groupService.updateGroupStats(groupId, 3600, 100);Updates:
- Adds to
totalHours - Adds to
groupXp - Recalculates
groupLevel
Get all groups ranked by level.
Returns:
- Array of
GroupLeaderboardEntryobjects with:groupIdgroupNamegroupLevelgroupXptotalHoursmemberCountrank
Example:
const leaderboard = await groupService.getGroupLeaderboard();
leaderboard.forEach((group, index) => {
console.log(`#${index + 1} ${group.groupName} - Lv ${group.groupLevel}`);
});Get all public groups with available space.
Returns:
- Array of
Groupobjects where:isPublic === truemembers.length < maxMembers
Example:
const publicGroups = await groupService.findPublicGroups();
console.log(`${publicGroups.length} groups available to join`);Manages session feed posts and reactions.
constructor(db: Firestore)createPost(messageId: string, userId: string, username: string, guildId: string, channelId: string, sessionId: string, duration: number, xpGained: number, levelGained?: number, achievementsUnlocked?: string[]): Promise<void>
Create a session post in the feed.
Parameters:
messageId- Discord message IDuserId- User who completed sessionusername- User's usernameguildId- Discord server IDchannelId- Feed channel IDsessionId- Completed session IDduration- Session duration in secondsxpGained- XP earnedlevelGained(optional) - New level if leveled upachievementsUnlocked(optional) - Array of achievement IDs unlocked
Example:
await postService.createPost(
message.id,
userId,
username,
guildId,
channelId,
sessionId,
3600,
100,
5,
['centurion']
);Firestore write:
discord-data/sessionPosts/posts/{messageId}
{
messageId,
userId,
username,
guildId,
channelId,
sessionId,
duration,
xpGained,
levelGained?,
achievementsUnlocked?,
postedAt: Timestamp.now(),
reactions: {},
cheers: []
}
Add a reaction to a post.
Parameters:
messageId- Post message IDemoji- Emoji useduserId- User who reacted
Example:
await postService.addReaction(message.id, '❤️', userId);Remove a reaction from a post.
Parameters:
messageId- Post message IDemoji- Emoji useduserId- User who reacted
Example:
await postService.removeReaction(message.id, '❤️', userId);Manages study events and RSVPs.
constructor(db: Firestore)Create a new study event.
Parameters:
eventData- Object with event details
Returns:
- Event ID (UUID)
Example:
const eventId = await eventService.createEvent({
serverId: guildId,
creatorId: userId,
creatorUsername: username,
title: 'Library Study Session',
location: 'Main Library',
startTime: Timestamp.fromDate(new Date('2025-02-15T14:00:00')),
duration: 120,
studyType: 'silent'
});Get all future events for a server.
Parameters:
serverId- Discord server ID
Returns:
- Array of
StudyEventobjects wherestartTime> now
Example:
const events = await eventService.getUpcomingEvents(guildId);
events.forEach(event => {
console.log(`${event.title} - ${event.startTime}`);
});RSVP a user to an event.
Parameters:
eventId- Event IDuserId- User IDusername- User's username
Example:
await eventService.addAttendee(eventId, userId, username);Remove RSVP from an event.
Parameters:
eventId- Event IDuserId- User ID
Example:
await eventService.removeAttendee(eventId, userId);Cancel an event (creator only).
Parameters:
eventId- Event IDuserId- User ID (must be creator)
Throws:
- Error if user is not the event creator
Example:
await eventService.cancelEvent(eventId, userId);Manages daily goals.
constructor(db: Firestore)addGoal(userId: string, username: string, goalText: string, difficulty: 'easy' | 'medium' | 'hard'): Promise<string>
Add a new goal.
Parameters:
userId- Discord user IDusername- User's usernamegoalText- Goal descriptiondifficulty- Goal difficulty (affects XP reward)
Returns:
- Goal ID (UUID)
Example:
const goalId = await dailyGoalService.addGoal(
userId,
username,
'Complete 3 React exercises',
'medium'
);XP Rewards:
- Easy: 10 XP
- Medium: 25 XP
- Hard: 50 XP
Get all active goals for a user.
Parameters:
userId- Discord user ID
Returns:
- Array of
Goalobjects
Example:
const goals = await dailyGoalService.getGoals(userId);
goals.forEach(goal => {
console.log(`${goal.text} (${goal.difficulty})`);
});Mark a goal as completed.
Parameters:
userId- Discord user IDgoalId- Goal ID to complete
Returns:
- XP awarded (based on difficulty)
Example:
const xp = await dailyGoalService.completeGoal(userId, goalId);
console.log(`Goal completed! +${xp} XP`);Delete a goal without completing it.
Parameters:
userId- Discord user IDgoalId- Goal ID to delete
Example:
await dailyGoalService.deleteGoal(userId, goalId);All image services share a common pattern:
Generate profile card images.
Generate a profile card image.
Parameters:
userData- Object with:username- Display nameavatarUrl- Avatar URLlevel- User levelxp- Total XPstats- User stats objectachievements- Recent achievements
Returns:
- Discord
AttachmentBuilderwith PNG image
Example:
const image = await profileImageService.generate({
username: 'Alice',
avatarUrl: 'https://...',
level: 10,
xp: 2450,
stats: userStats,
achievements: ['centurion', 'hot_streak']
});
await interaction.reply({ files: [image] });Generate statistics chart images.
Generate a stats chart image.
Parameters:
userData- Object with user statstimeframe- 'daily' | 'weekly' | 'monthly' | 'yearly' | 'all'
Returns:
- Discord
AttachmentBuilderwith PNG image
Example:
const image = await statsImageService.generate(userData, 'weekly');
await interaction.reply({ files: [image] });Generate session post images.
Generate a session completion post image.
Parameters:
postData- Object with:usernameavatarUrltitledescriptiondurationxpGainedlevelachievementsUnlocked
Returns:
- Discord
AttachmentBuilderwith PNG image
Generate group overview images.
Generate a group overview card image.
Parameters:
groupData- Object with:groupNamegroupLevelgroupXptotalHoursmembers- Array of member data
Returns:
- Discord
AttachmentBuilderwith PNG image
Manage user badges (future feature).
Manage weekly XP challenges (future feature).
All services throw errors that should be caught by command handlers:
try {
await sessionService.createActiveSession(...);
} catch (error) {
console.error('Failed to create session:', error);
await interaction.reply({
content: '❌ Failed to start session. Please try again.',
ephemeral: true
});
}For operations that modify multiple documents, use transactions:
await db.runTransaction(async (transaction) => {
// Read operations
const sessionDoc = await transaction.get(sessionRef);
const statsDoc = await transaction.get(statsRef);
// Write operations
transaction.update(statsRef, { ... });
transaction.set(sessionRef, { ... });
});Always use Firebase Timestamp for date/time fields:
import { Timestamp } from 'firebase-admin/firestore';
const session = {
startTime: Timestamp.now(),
// ...
};Use Promise.all() for parallel operations:
const [stats, session, achievements] = await Promise.all([
statsService.getUserStats(userId),
sessionService.getActiveSession(userId),
achievementService.getUserAchievements(userId)
]);All interfaces are defined in /src/types.ts:
ActiveSession- Active session dataCompletedSession- Completed session dataUserStats- User statisticsGroup- Study group dataGroupMembership- User group membershipStudyEvent- Event dataGoal- Daily goal dataSessionPost- Feed post dataAchievementDefinition- Achievement definition
See types.ts for complete interface definitions.
- Architecture Documentation - System architecture overview
- Commands Reference - All available commands
- Setup Guide - Installation and configuration
- Deployment Guide - Deploy to Railway