diff --git a/backend/src/streak/providers/streaks.service.ts b/backend/src/streak/providers/streaks.service.ts new file mode 100644 index 0000000..5cc3391 --- /dev/null +++ b/backend/src/streak/providers/streaks.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { UpdateStreakProvider } from './update-streak.provider'; +import { Streak } from '../entities/streak.entity'; + +@Injectable() +export class StreaksService { + constructor( + private readonly updateStreakProvider: UpdateStreakProvider, + ) {} + + /** + * Get user's current streak + */ + async getStreak(userId: number): Promise { + return this.updateStreakProvider.getStreak(userId); + } + + /** + * Update streak after daily quest completion + * Handles increment, reset, and longest streak tracking + */ + async updateStreak(userId: number): Promise { + return this.updateStreakProvider.updateStreak(userId); + } +} diff --git a/backend/src/streak/streaks.controller.ts b/backend/src/streak/streaks.controller.ts new file mode 100644 index 0000000..3bf1eac --- /dev/null +++ b/backend/src/streak/streaks.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Post, UnauthorizedException } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { StreaksService } from './providers/streaks.service'; +import { Streak } from './entities/streak.entity'; +import { ActiveUser } from '../auth/decorators/activeUser.decorator'; +import { ActiveUserData } from '../auth/interfaces/activeInterface'; +import { Auth } from '../auth/decorators/auth.decorator'; +import { authType } from '../auth/enum/auth-type.enum'; + +@ApiTags('streaks') +@Controller('streaks') +export class StreaksController { + constructor(private readonly streaksService: StreaksService) {} + + @Get() + @Auth(authType.Bearer) + @ApiOperation({ summary: 'Get current user streak' }) + @ApiResponse({ + status: 200, + description: 'User streak retrieved successfully', + type: Streak, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized', + }) + async getStreak(@ActiveUser() user: ActiveUserData): Promise { + if (!user?.sub) { + throw new UnauthorizedException('User not authenticated'); + } + const userId = parseInt(user.sub, 10); + return this.streaksService.getStreak(userId); + } + + @Post('update') + @Auth(authType.Bearer) + @ApiOperation({ summary: 'Update streak after daily quest completion' }) + @ApiResponse({ + status: 200, + description: 'Streak updated successfully', + type: Streak, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized', + }) + async updateStreak(@ActiveUser() user: ActiveUserData): Promise { + if (!user?.sub) { + throw new UnauthorizedException('User not authenticated'); + } + const userId = parseInt(user.sub, 10); + return this.streaksService.updateStreak(userId); + } +} diff --git a/backend/src/streak/strerak.module.ts b/backend/src/streak/strerak.module.ts index 371bca8..7e8d7a3 100644 --- a/backend/src/streak/strerak.module.ts +++ b/backend/src/streak/strerak.module.ts @@ -2,10 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Streak } from './entities/streak.entity'; import { UpdateStreakProvider } from './providers/update-streak.provider'; +import { StreaksService } from './providers/streaks.service'; +import { StreaksController } from './streaks.controller'; @Module({ imports: [TypeOrmModule.forFeature([Streak])], - providers: [UpdateStreakProvider], - exports: [TypeOrmModule, UpdateStreakProvider], + controllers: [StreaksController], + providers: [UpdateStreakProvider, StreaksService], + exports: [TypeOrmModule, UpdateStreakProvider, StreaksService], }) export class StreakModule {} diff --git a/frontend/hooks/useStreak.ts b/frontend/hooks/useStreak.ts new file mode 100644 index 0000000..d036707 --- /dev/null +++ b/frontend/hooks/useStreak.ts @@ -0,0 +1,64 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "../lib/reduxHooks"; +import { + fetchStreakThunk, + updateStreakThunk, + resetStreak, + clearStreakError, +} from "../lib/features/streak/streakSlice"; + +export interface UseStreakOptions { + autoFetch?: boolean; +} + +export function useStreak(options: UseStreakOptions = {}) { + const { autoFetch = true } = options; + + const dispatch = useAppDispatch(); + const streakState = useAppSelector((state) => state.streak); + + const { + currentStreak, + longestStreak, + streakDates, + isLoading, + error, + } = streakState; + + // Auto-fetch streak on mount if requested + useEffect(() => { + if (autoFetch) { + dispatch(fetchStreakThunk()); + } + }, [autoFetch, dispatch]); + + const fetchStreak = useCallback(() => { + dispatch(fetchStreakThunk()); + }, [dispatch]); + + const updateStreak = useCallback(async () => { + await dispatch(updateStreakThunk()).unwrap(); + }, [dispatch]); + + const clearError = useCallback(() => { + dispatch(clearStreakError()); + }, [dispatch]); + + const reset = useCallback(() => { + dispatch(resetStreak()); + }, [dispatch]); + + return { + currentStreak, + longestStreak, + streakDates, + isLoading, + error, + fetchStreak, + updateStreak, + clearError, + reset, + }; +} diff --git a/frontend/lib/api/streakApi.ts b/frontend/lib/api/streakApi.ts new file mode 100644 index 0000000..db6cb10 --- /dev/null +++ b/frontend/lib/api/streakApi.ts @@ -0,0 +1,75 @@ +export interface StreakResponseDto { + id: number; + userId: number; + currentStreak: number; + longestStreak: number; + lastActivityDate?: string; + streakDates: string[]; + updatedAt: string; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; + +function getAuthHeaders(): Record { + if (typeof window === "undefined") { + return {}; + } + + const token = window.localStorage.getItem("accessToken"); + + if (!token) { + return {}; + } + + return { + Authorization: `Bearer ${token}`, + }; +} + +async function handleResponse(response: Response): Promise { + const contentType = response.headers.get("Content-Type"); + const isJson = contentType && contentType.includes("application/json"); + + const data = isJson ? await response.json() : null; + + if (!response.ok) { + const message = + (data && (data.message as string | undefined)) || + `Request failed with status ${response.status}`; + throw new Error(message); + } + + return data as T; +} + +export async function fetchStreak(): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + ...getAuthHeaders(), + }; + + const response = await fetch(`${API_BASE_URL}/streaks`, { + method: "GET", + headers, + }); + + if (response.status === 404) { + return null; + } + + return handleResponse(response); +} + +export async function updateStreak(): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + ...getAuthHeaders(), + }; + + const response = await fetch(`${API_BASE_URL}/streaks/update`, { + method: "POST", + headers, + }); + + return handleResponse(response); +} diff --git a/frontend/lib/features/streak/streakSlice.ts b/frontend/lib/features/streak/streakSlice.ts new file mode 100644 index 0000000..72166b7 --- /dev/null +++ b/frontend/lib/features/streak/streakSlice.ts @@ -0,0 +1,108 @@ +"use client"; + +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import { fetchStreak, updateStreak, StreakResponseDto } from "../../api/streakApi"; + +export interface StreakState { + currentStreak: number; + longestStreak: number; + streakDates: string[]; + isLoading: boolean; + error: string | null; +} + +const initialState: StreakState = { + currentStreak: 0, + longestStreak: 0, + streakDates: [], + isLoading: false, + error: null, +}; + +export const fetchStreakThunk = createAsyncThunk( + "streak/fetchStreak", + async (_, { rejectWithValue }) => { + try { + const streak = await fetchStreak(); + return streak; + } catch (error) { + return rejectWithValue( + error instanceof Error ? error.message : "Failed to fetch streak" + ); + } + } +); + +export const updateStreakThunk = createAsyncThunk( + "streak/updateStreak", + async (_, { rejectWithValue }) => { + try { + const streak = await updateStreak(); + return streak; + } catch (error) { + return rejectWithValue( + error instanceof Error ? error.message : "Failed to update streak" + ); + } + } +); + +const streakSlice = createSlice({ + name: "streak", + initialState, + reducers: { + resetStreak: (state) => { + state.currentStreak = 0; + state.longestStreak = 0; + state.streakDates = []; + state.error = null; + }, + clearStreakError: (state) => { + state.error = null; + }, + }, + extraReducers: (builder) => { + builder + // Fetch streak + .addCase(fetchStreakThunk.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase( + fetchStreakThunk.fulfilled, + (state, action: PayloadAction) => { + state.isLoading = false; + if (action.payload) { + state.currentStreak = action.payload.currentStreak; + state.longestStreak = action.payload.longestStreak; + state.streakDates = action.payload.streakDates || []; + } + } + ) + .addCase(fetchStreakThunk.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }) + // Update streak + .addCase(updateStreakThunk.pending, (state) => { + state.isLoading = true; + state.error = null; + }) + .addCase( + updateStreakThunk.fulfilled, + (state, action: PayloadAction) => { + state.isLoading = false; + state.currentStreak = action.payload.currentStreak; + state.longestStreak = action.payload.longestStreak; + state.streakDates = action.payload.streakDates || []; + } + ) + .addCase(updateStreakThunk.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { resetStreak, clearStreakError } = streakSlice.actions; +export default streakSlice.reducer; diff --git a/frontend/lib/store.ts b/frontend/lib/store.ts index a237e0e..be2bc15 100644 --- a/frontend/lib/store.ts +++ b/frontend/lib/store.ts @@ -1,10 +1,12 @@ import { configureStore } from '@reduxjs/toolkit'; import quizReducer from './features/quiz/quizSlice'; +import streakReducer from './features/streak/streakSlice'; export const makeStore = () => { return configureStore({ reducer: { quiz: quizReducer, + streak: streakReducer, }, }); };