personal-lifecycle-tracker / frontend / src / services / backendLifecycleService.ts
backendLifecycleService.ts
Raw
import { ILifecycleService } from './api';
import {
  CreateItemDTO,
  DailyDigestConfig,
  DailyDigestEmail,
  Group,
  LifecycleItem,
  LifecycleType,
  RemindersSummary,
  UpdateItemDTO,
  User,
} from './types';

const STORAGE_KEYS = {
  TOKEN: 'plt_auth_token_v1',
  CURRENT_USER_ID: 'plt_current_user_id_v1',
  USER_TOKENS: 'plt_user_tokens_v1',
  EXPLICIT_LOGOUT: 'plt_logged_out_v1',
};

const DEFAULT_SEED_PASSWORD = 'password123';

export class BackendLifecycleService implements ILifecycleService {
  private baseUrl: string;
  private token: string | null = null;
  private currentUserId: string | null = null;
  private userTokens: Record<string, string> = {};
  private explicitlyLoggedOut: boolean = false;

  constructor(baseUrl?: string) {
    if (baseUrl !== undefined) {
      this.baseUrl = baseUrl.replace(/\/$/, '');
    } else {
      const envUrl =
        typeof import.meta !== 'undefined' && import.meta.env
          ? import.meta.env.VITE_API_URL
          : '';
      let resolvedBase = envUrl || '';
      if (!resolvedBase && typeof window !== 'undefined' && window.location) {
        const host = window.location.hostname;
        if (host.includes('cloudfront.net')) {
          resolvedBase = 'https://7ksomsylvke2uawuhhccbfr6hy0agzyb.lambda-url.ap-south-1.on.aws';
        }
      }
      this.baseUrl = resolvedBase.replace(/\/$/, '');
    }
    this.loadState();
  }

  private isStorageAvailable(): boolean {
    return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
  }

  private loadState(): void {
    if (!this.isStorageAvailable()) return;

    try {
      this.token = localStorage.getItem(STORAGE_KEYS.TOKEN);
      this.currentUserId = localStorage.getItem(STORAGE_KEYS.CURRENT_USER_ID);
      this.explicitlyLoggedOut = localStorage.getItem(STORAGE_KEYS.EXPLICIT_LOGOUT) === 'true';

      const storedUserTokens = localStorage.getItem(STORAGE_KEYS.USER_TOKENS);
      if (storedUserTokens) {
        this.userTokens = JSON.parse(storedUserTokens);
      }
    } catch {
      // Storage access or parse error fallback
    }
  }

  private saveState(): void {
    if (!this.isStorageAvailable()) return;

    try {
      if (this.token) {
        localStorage.setItem(STORAGE_KEYS.TOKEN, this.token);
      } else {
        localStorage.removeItem(STORAGE_KEYS.TOKEN);
      }

      if (this.currentUserId) {
        localStorage.setItem(STORAGE_KEYS.CURRENT_USER_ID, this.currentUserId);
      } else {
        localStorage.removeItem(STORAGE_KEYS.CURRENT_USER_ID);
      }

      if (this.explicitlyLoggedOut) {
        localStorage.setItem(STORAGE_KEYS.EXPLICIT_LOGOUT, 'true');
      } else {
        localStorage.removeItem(STORAGE_KEYS.EXPLICIT_LOGOUT);
      }

      localStorage.setItem(STORAGE_KEYS.USER_TOKENS, JSON.stringify(this.userTokens));
    } catch {
      // Ignore quota errors
    }
  }

  private setSession(user: User, token: string): void {
    this.token = token;
    this.currentUserId = user.id;
    this.userTokens[user.id] = token;
    this.explicitlyLoggedOut = false;
    this.saveState();
  }

  private clearSession(): void {
    this.token = null;
    this.currentUserId = null;
    this.explicitlyLoggedOut = true;
    this.saveState();
  }

  private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;
    const headers = new Headers(options.headers || {});

    if (!headers.has('Content-Type') && options.body && typeof options.body === 'string') {
      headers.set('Content-Type', 'application/json');
    }

    if (this.token && !headers.has('Authorization')) {
      headers.set('Authorization', `Bearer ${this.token}`);
    }

    const response = await fetch(url, {
      ...options,
      headers,
    });

    if (!response.ok) {
      let errorMessage = `HTTP error ${response.status}`;
      try {
        const errorJson = await response.json();
        if (errorJson?.error) {
          errorMessage = errorJson.error;
        } else if (errorJson?.detail) {
          errorMessage =
            typeof errorJson.detail === 'string'
              ? errorJson.detail
              : JSON.stringify(errorJson.detail);
        }
      } catch {
        // Response might not be JSON
      }
      throw new Error(errorMessage);
    }

    if (response.status === 204) {
      return undefined as unknown as T;
    }

    const contentType = response.headers.get('content-type');
    if (contentType && contentType.includes('application/json')) {
      return (await response.json()) as T;
    }

    return undefined as unknown as T;
  }

  // ------------------ AUTH & USER MANAGEMENT ------------------

  public async getCurrentUser(): Promise<User | null> {
    if (this.token) {
      try {
        const user = await this.request<User>('/api/v1/auth/me', { method: 'GET' });
        this.currentUserId = user.id;
        this.saveState();
        return user;
      } catch {
        // Token expired or invalid
        this.token = null;
        this.currentUserId = null;
        this.saveState();
      }
    }

    // If user explicitly logged out, do not auto-login
    if (this.explicitlyLoggedOut) {
      return null;
    }

    // Default seamless initial user experience: login default seed user Alex Taylor
    try {
      const user = await this.login('alex@example.com', DEFAULT_SEED_PASSWORD);
      return user;
    } catch {
      return null;
    }
  }

  public async login(email: string, password?: string): Promise<User> {
    const trimmedEmail = email.trim().toLowerCase();
    const payload = {
      email: trimmedEmail,
      password: password || DEFAULT_SEED_PASSWORD,
    };

    const res = await this.request<{ user: User; token: string }>('/api/v1/auth/login', {
      method: 'POST',
      body: JSON.stringify(payload),
    });

    this.setSession(res.user, res.token);
    return res.user;
  }

  public async signup(email: string, password?: string, name?: string): Promise<User> {
    const trimmedEmail = email.trim().toLowerCase();
    const payload = {
      email: trimmedEmail,
      password: password || DEFAULT_SEED_PASSWORD,
      name: name?.trim() || trimmedEmail.split('@')[0],
    };

    const res = await this.request<{ user: User; token: string }>('/api/v1/auth/signup', {
      method: 'POST',
      body: JSON.stringify(payload),
    });

    this.setSession(res.user, res.token);
    return res.user;
  }

  public async logout(): Promise<void> {
    try {
      await this.request<{ message: string }>('/api/v1/auth/logout', { method: 'POST' });
    } catch {
      // Ignore network errors during logout
    }
    this.clearSession();
  }

  public async switchUser(userId: string): Promise<User> {
    // 1. Check if we already have a cached token for this user
    const cachedToken = this.userTokens[userId];
    if (cachedToken) {
      this.token = cachedToken;
      try {
        const user = await this.request<User>('/api/v1/auth/me', { method: 'GET' });
        this.setSession(user, cachedToken);
        return user;
      } catch {
        // Cached token invalid/expired, proceed to re-login
      }
    }

    // 2. Fetch available users to find target user email
    const users = await this.listUsers();
    const targetUser = users.find((u) => u.id === userId);
    if (!targetUser) {
      throw new Error('User not found');
    }

    // 3. Log in with seed password
    const user = await this.login(targetUser.email, DEFAULT_SEED_PASSWORD);
    return user;
  }

  public async listUsers(): Promise<User[]> {
    return this.request<User[]>('/api/v1/users', { method: 'GET' });
  }

  public async deleteAccount(userId: string): Promise<void> {
    await this.request<void>(`/api/v1/users/${encodeURIComponent(userId)}`, {
      method: 'DELETE',
    });

    delete this.userTokens[userId];
    if (this.currentUserId === userId) {
      this.clearSession();
    } else {
      this.saveState();
    }
  }

  // ------------------ LIFECYCLE ITEMS MANAGEMENT ------------------

  public async getItems(options?: {
    groupId?: string | null;
    type?: LifecycleType;
    searchQuery?: string;
  }): Promise<LifecycleItem[]> {
    const params = new URLSearchParams();
    if (options?.groupId !== undefined) {
      params.set('groupId', options.groupId === null ? 'null' : options.groupId);
    }
    if (options?.type) {
      params.set('type', options.type);
    }
    if (options?.searchQuery) {
      params.set('searchQuery', options.searchQuery);
    }

    const query = params.toString();
    const endpoint = `/api/v1/items${query ? `?${query}` : ''}`;
    return this.request<LifecycleItem[]>(endpoint, { method: 'GET' });
  }

  public async getItem(id: string): Promise<LifecycleItem> {
    return this.request<LifecycleItem>(`/api/v1/items/${encodeURIComponent(id)}`, {
      method: 'GET',
    });
  }

  public async createItem(data: CreateItemDTO): Promise<LifecycleItem> {
    return this.request<LifecycleItem>('/api/v1/items', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  public async updateItem(id: string, data: UpdateItemDTO): Promise<LifecycleItem> {
    return this.request<LifecycleItem>(`/api/v1/items/${encodeURIComponent(id)}`, {
      method: 'PATCH',
      body: JSON.stringify(data),
    });
  }

  public async toggleItemCompletion(id: string): Promise<LifecycleItem> {
    return this.request<LifecycleItem>(`/api/v1/items/${encodeURIComponent(id)}/complete`, {
      method: 'POST',
    });
  }

  public async deleteItem(id: string): Promise<void> {
    await this.request<void>(`/api/v1/items/${encodeURIComponent(id)}`, {
      method: 'DELETE',
    });
  }

  // ------------------ GROUPS & SHARING ------------------

  public async getGroups(): Promise<Group[]> {
    return this.request<Group[]>('/api/v1/groups', { method: 'GET' });
  }

  public async getGroup(id: string): Promise<Group> {
    return this.request<Group>(`/api/v1/groups/${encodeURIComponent(id)}`, {
      method: 'GET',
    });
  }

  public async createGroup(name: string): Promise<Group> {
    return this.request<Group>('/api/v1/groups', {
      method: 'POST',
      body: JSON.stringify({ name }),
    });
  }

  public async inviteMember(groupId: string, memberEmail: string): Promise<Group> {
    return this.request<Group>(`/api/v1/groups/${encodeURIComponent(groupId)}/members`, {
      method: 'POST',
      body: JSON.stringify({ email: memberEmail }),
    });
  }

  public async removeMember(groupId: string, memberEmail: string): Promise<Group> {
    return this.request<Group>(
      `/api/v1/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(memberEmail)}`,
      {
        method: 'DELETE',
      }
    );
  }

  public async leaveGroup(groupId: string): Promise<void> {
    await this.request<{ message: string }>(
      `/api/v1/groups/${encodeURIComponent(groupId)}/leave`,
      {
        method: 'POST',
      }
    );
  }

  public async deleteGroup(groupId: string): Promise<void> {
    await this.request<void>(`/api/v1/groups/${encodeURIComponent(groupId)}`, {
      method: 'DELETE',
    });
  }

  // ------------------ REMINDERS & EMAIL DIGEST ------------------

  public async getRemindersSummary(): Promise<RemindersSummary> {
    return this.request<RemindersSummary>('/api/v1/reminders/summary', {
      method: 'GET',
    });
  }

  public async getDigestConfig(): Promise<DailyDigestConfig> {
    return this.request<DailyDigestConfig>('/api/v1/reminders/digest/config', {
      method: 'GET',
    });
  }

  public async updateDigestConfig(
    config: Partial<DailyDigestConfig>
  ): Promise<DailyDigestConfig> {
    return this.request<DailyDigestConfig>('/api/v1/reminders/digest/config', {
      method: 'PATCH',
      body: JSON.stringify(config),
    });
  }

  public async generateAndSendDailyDigest(
    recipientEmail?: string
  ): Promise<DailyDigestEmail> {
    return this.request<DailyDigestEmail>('/api/v1/reminders/digest/generate', {
      method: 'POST',
      body: JSON.stringify(recipientEmail ? { recipientEmail } : {}),
    });
  }

  public async getDigestHistory(): Promise<DailyDigestEmail[]> {
    return this.request<DailyDigestEmail[]>('/api/v1/reminders/digest/history', {
      method: 'GET',
    });
  }

  // ------------------ DEVELOPMENT & SEEDING ------------------

  public async resetToDefaultSeed(): Promise<void> {
    await this.request<{ message: string }>('/api/v1/dev/seed', {
      method: 'POST',
    });
  }
}