494 lines
17 KiB
TypeScript
494 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { supabaseClient } from "@/lib/supabase/client";
|
|
import type { Database, ActivityItem } from "@/lib/supabase/database.types";
|
|
|
|
type Task = Database['public']['Tables']['tasks']['Row'];
|
|
type Project = Database['public']['Tables']['projects']['Row'];
|
|
|
|
export type ActivityFilterType = 'all' | 'task_created' | 'task_completed' | 'task_updated' | 'comment_added' | 'task_assigned';
|
|
|
|
interface UseActivityFeedOptions {
|
|
limit?: number;
|
|
projectId?: string;
|
|
filterType?: ActivityFilterType;
|
|
}
|
|
|
|
let canUseLeanTasksSelect = true;
|
|
|
|
interface UserDirectoryEntry {
|
|
name: string;
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
type UserDirectory = Record<string, UserDirectoryEntry>;
|
|
|
|
interface NormalizedTaskComment {
|
|
id: string;
|
|
text: string;
|
|
createdAt: string;
|
|
commentAuthorId: string;
|
|
replies: NormalizedTaskComment[];
|
|
}
|
|
|
|
interface UserDirectoryRow {
|
|
id: string;
|
|
name: string | null;
|
|
avatar_url: string | null;
|
|
}
|
|
|
|
function normalizeUserId(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized.length > 0 ? normalized : null;
|
|
}
|
|
|
|
function resolveUserName(
|
|
userId: string | null,
|
|
users: UserDirectory,
|
|
fallbackName?: string | null
|
|
): string {
|
|
const normalizedUserId = normalizeUserId(userId);
|
|
if (normalizedUserId && users[normalizedUserId]?.name) return users[normalizedUserId].name;
|
|
if (fallbackName && fallbackName.trim().length > 0) return fallbackName;
|
|
if (!userId) return 'Unknown';
|
|
|
|
const normalizedId = userId.trim().toLowerCase();
|
|
if (normalizedId === "assistant") return "Assistant";
|
|
if (normalizedId === "user" || normalizedId === "legacy-user") return "User";
|
|
return "User";
|
|
}
|
|
|
|
function resolveUserAvatarUrl(userId: string | null, users: UserDirectory): string | undefined {
|
|
const normalizedUserId = normalizeUserId(userId);
|
|
if (!normalizedUserId) return undefined;
|
|
return users[normalizedUserId]?.avatarUrl;
|
|
}
|
|
|
|
function isValidTimestamp(value: string | null | undefined): value is string {
|
|
return !!value && !Number.isNaN(new Date(value).getTime());
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function pickTimestamp(primary?: string | null, fallback?: string | null): string {
|
|
if (isValidTimestamp(primary)) return primary;
|
|
if (isValidTimestamp(fallback)) return fallback;
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function normalizeTaskComments(value: unknown): NormalizedTaskComment[] {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const comments: NormalizedTaskComment[] = [];
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== "object") continue;
|
|
const candidate = entry as Record<string, unknown>;
|
|
|
|
const id = toNonEmptyString(candidate.id);
|
|
const text = toNonEmptyString(candidate.text);
|
|
const createdAt = toNonEmptyString(candidate.createdAt);
|
|
const commentAuthorId = toNonEmptyString(candidate.commentAuthorId);
|
|
|
|
if (!id || !text || !createdAt || !commentAuthorId) continue;
|
|
|
|
comments.push({
|
|
id,
|
|
text,
|
|
createdAt: pickTimestamp(createdAt, null),
|
|
commentAuthorId,
|
|
replies: normalizeTaskComments(candidate.replies),
|
|
});
|
|
}
|
|
|
|
return comments;
|
|
}
|
|
|
|
function flattenTaskComments(
|
|
comments: NormalizedTaskComment[],
|
|
pathPrefix = ""
|
|
): Array<{ comment: NormalizedTaskComment; path: string }> {
|
|
const flattened: Array<{ comment: NormalizedTaskComment; path: string }> = [];
|
|
|
|
comments.forEach((comment, index) => {
|
|
const path = pathPrefix ? `${pathPrefix}.${index}` : `${index}`;
|
|
flattened.push({ comment, path });
|
|
|
|
if (comment.replies.length > 0) {
|
|
flattened.push(...flattenTaskComments(comment.replies, path));
|
|
}
|
|
});
|
|
|
|
return flattened;
|
|
}
|
|
|
|
function buildUserDirectory(rows: Array<{ id: string; name: string | null; avatar_url: string | null }>): UserDirectory {
|
|
const directory: UserDirectory = {};
|
|
|
|
rows.forEach((row) => {
|
|
const rawId = toNonEmptyString(row.id);
|
|
const id = normalizeUserId(rawId);
|
|
const name = toNonEmptyString(row.name);
|
|
if (!id || !name) return;
|
|
|
|
directory[id] = {
|
|
name,
|
|
avatarUrl: toNonEmptyString(row.avatar_url) ?? undefined,
|
|
};
|
|
});
|
|
|
|
return directory;
|
|
}
|
|
|
|
export function useActivityFeed(options: UseActivityFeedOptions = {}) {
|
|
const { limit = 50, projectId, filterType = 'all' } = options;
|
|
const isDebug = process.env.NODE_ENV !== "production";
|
|
|
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchProjects = useCallback(async () => {
|
|
try {
|
|
const { data, error } = await supabaseClient
|
|
.from('projects')
|
|
.select('id, name, color')
|
|
.order('name');
|
|
|
|
if (error) throw error;
|
|
setProjects(data || []);
|
|
} catch (err) {
|
|
console.error('Error fetching projects:', err);
|
|
}
|
|
}, []);
|
|
|
|
const fetchActivities = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
let userRows: UserDirectoryRow[] = [];
|
|
try {
|
|
const response = await fetch("/api/users/directory", { cache: "no-store" });
|
|
if (response.ok) {
|
|
const payload = (await response.json()) as { users?: UserDirectoryRow[] };
|
|
userRows = Array.isArray(payload.users) ? payload.users : [];
|
|
} else {
|
|
if (isDebug) {
|
|
console.warn("[activity-feed] users directory API returned non-OK status", { status: response.status });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isDebug) {
|
|
console.warn("[activity-feed] users directory API request failed, falling back to client query", error);
|
|
}
|
|
}
|
|
|
|
if (userRows.length === 0) {
|
|
const { data: fallbackUserRows, error: usersError } = await supabaseClient
|
|
.from("users")
|
|
.select("id, name, avatar_url");
|
|
if (usersError) throw usersError;
|
|
userRows = (fallbackUserRows || []) as UserDirectoryRow[];
|
|
}
|
|
|
|
const userDirectory = buildUserDirectory(userRows);
|
|
if (isDebug) {
|
|
console.log("[activity-feed] user directory loaded", {
|
|
userCount: Object.keys(userDirectory).length,
|
|
sampleUserIds: Object.keys(userDirectory).slice(0, 5),
|
|
});
|
|
}
|
|
|
|
const runTasksQuery = async (selectClause: string) => {
|
|
let query = supabaseClient
|
|
.from('tasks')
|
|
.select(selectClause)
|
|
.order('updated_at', { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (projectId) {
|
|
query = query.eq('project_id', projectId);
|
|
}
|
|
|
|
return query;
|
|
};
|
|
|
|
let tasks: unknown[] | null = null;
|
|
let tasksError: unknown = null;
|
|
|
|
if (canUseLeanTasksSelect) {
|
|
const leanResult = await runTasksQuery(`
|
|
id,
|
|
title,
|
|
status,
|
|
project_id,
|
|
created_at,
|
|
updated_at,
|
|
created_by_id,
|
|
created_by_name,
|
|
updated_by_id,
|
|
updated_by_name,
|
|
assignee_id,
|
|
assignee_name,
|
|
comments,
|
|
projects:project_id (name, color)
|
|
`);
|
|
tasks = leanResult.data;
|
|
tasksError = leanResult.error;
|
|
}
|
|
|
|
if (tasksError || !canUseLeanTasksSelect) {
|
|
if (tasksError) canUseLeanTasksSelect = false;
|
|
const fallback = await runTasksQuery(`
|
|
*,
|
|
projects:project_id (*),
|
|
assignee:assignee_id (id, name)
|
|
`);
|
|
tasks = fallback.data;
|
|
tasksError = fallback.error;
|
|
}
|
|
|
|
if (tasksError) throw tasksError;
|
|
|
|
// Convert tasks to activity items
|
|
const activityItems: ActivityItem[] = [];
|
|
|
|
const typedTasks = (tasks || []) as Array<Task & {
|
|
projects: Pick<Project, "name" | "color"> | null;
|
|
assignee?: { id?: string; name?: string | null } | null;
|
|
}>;
|
|
|
|
const globalTaskUserNameById: Record<string, string> = {};
|
|
typedTasks.forEach((task) => {
|
|
const createdById = normalizeUserId(task.created_by_id);
|
|
const updatedById = normalizeUserId(task.updated_by_id);
|
|
const assigneeId = normalizeUserId(task.assignee_id);
|
|
|
|
if (createdById && task.created_by_name) globalTaskUserNameById[createdById] = task.created_by_name;
|
|
if (updatedById && task.updated_by_name) globalTaskUserNameById[updatedById] = task.updated_by_name;
|
|
if (assigneeId && (task.assignee_name || task.assignee?.name)) {
|
|
globalTaskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || "";
|
|
}
|
|
});
|
|
|
|
typedTasks.forEach((task) => {
|
|
const project = task.projects;
|
|
const taskUserNameById: Record<string, string> = {};
|
|
|
|
const createdById = normalizeUserId(task.created_by_id);
|
|
const updatedById = normalizeUserId(task.updated_by_id);
|
|
const assigneeId = normalizeUserId(task.assignee_id);
|
|
|
|
if (createdById && task.created_by_name) {
|
|
taskUserNameById[createdById] = task.created_by_name;
|
|
}
|
|
if (updatedById && task.updated_by_name) {
|
|
taskUserNameById[updatedById] = task.updated_by_name;
|
|
}
|
|
if (assigneeId && (task.assignee_name || task.assignee?.name)) {
|
|
taskUserNameById[assigneeId] = task.assignee_name || task.assignee?.name || "";
|
|
}
|
|
|
|
const createdByName = resolveUserName(
|
|
task.created_by_id,
|
|
userDirectory,
|
|
task.created_by_name || (createdById ? globalTaskUserNameById[createdById] : null) || null
|
|
);
|
|
const updatedByName = resolveUserName(
|
|
task.updated_by_id,
|
|
userDirectory,
|
|
task.updated_by_name || (updatedById ? globalTaskUserNameById[updatedById] : null) || null
|
|
);
|
|
const assigneeName = resolveUserName(
|
|
task.assignee_id,
|
|
userDirectory,
|
|
task.assignee_name || task.assignee?.name || (assigneeId ? globalTaskUserNameById[assigneeId] : null) || null
|
|
);
|
|
const createdByAvatarUrl = resolveUserAvatarUrl(task.created_by_id, userDirectory);
|
|
const updatedByAvatarUrl = resolveUserAvatarUrl(task.updated_by_id, userDirectory);
|
|
if (isDebug && (!task.created_by_id || createdByName === "User")) {
|
|
console.log("[activity-feed] created_by fallback", {
|
|
taskId: task.id,
|
|
createdById: task.created_by_id,
|
|
createdByNameFromTask: task.created_by_name,
|
|
resolvedName: createdByName,
|
|
});
|
|
}
|
|
|
|
// Task creation activity
|
|
if (filterType === 'all' || filterType === 'task_created') {
|
|
activityItems.push({
|
|
id: `${task.id}-created`,
|
|
type: 'task_created',
|
|
task_id: task.id,
|
|
task_title: task.title,
|
|
project_id: task.project_id,
|
|
project_name: project?.name || 'Unknown Project',
|
|
project_color: project?.color || '#6B7280',
|
|
user_id: task.created_by_id || '',
|
|
user_name: createdByName,
|
|
user_avatar_url: createdByAvatarUrl,
|
|
timestamp: pickTimestamp(task.created_at, task.updated_at),
|
|
});
|
|
}
|
|
|
|
// Task completion activity
|
|
if (task.status === 'done' && (filterType === 'all' || filterType === 'task_completed')) {
|
|
activityItems.push({
|
|
id: `${task.id}-completed`,
|
|
type: 'task_completed',
|
|
task_id: task.id,
|
|
task_title: task.title,
|
|
project_id: task.project_id,
|
|
project_name: project?.name || 'Unknown Project',
|
|
project_color: project?.color || '#6B7280',
|
|
user_id: task.updated_by_id || task.created_by_id || '',
|
|
user_name: updatedByName || createdByName,
|
|
user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl,
|
|
timestamp: pickTimestamp(task.updated_at, task.created_at),
|
|
});
|
|
}
|
|
|
|
// Assignment activity
|
|
if (task.assignee_id && (filterType === 'all' || filterType === 'task_assigned')) {
|
|
activityItems.push({
|
|
id: `${task.id}-assigned`,
|
|
type: 'task_assigned',
|
|
task_id: task.id,
|
|
task_title: task.title,
|
|
project_id: task.project_id,
|
|
project_name: project?.name || 'Unknown Project',
|
|
project_color: project?.color || '#6B7280',
|
|
user_id: task.updated_by_id || task.created_by_id || '',
|
|
user_name: updatedByName || createdByName,
|
|
user_avatar_url: updatedByAvatarUrl || createdByAvatarUrl,
|
|
timestamp: pickTimestamp(task.updated_at, task.created_at),
|
|
details: `Assigned to ${assigneeName}`,
|
|
});
|
|
}
|
|
|
|
// Task update activity (if updated after creation and not completed)
|
|
if (task.updated_at !== task.created_at &&
|
|
task.status !== 'done' &&
|
|
(filterType === 'all' || filterType === 'task_updated')) {
|
|
activityItems.push({
|
|
id: `${task.id}-updated`,
|
|
type: 'task_updated',
|
|
task_id: task.id,
|
|
task_title: task.title,
|
|
project_id: task.project_id,
|
|
project_name: project?.name || 'Unknown Project',
|
|
project_color: project?.color || '#6B7280',
|
|
user_id: task.updated_by_id || '',
|
|
user_name: updatedByName,
|
|
user_avatar_url: updatedByAvatarUrl,
|
|
timestamp: pickTimestamp(task.updated_at, task.created_at),
|
|
details: `Status: ${task.status}`,
|
|
});
|
|
}
|
|
|
|
// Comment activities
|
|
if (task.comments && Array.isArray(task.comments) && (filterType === 'all' || filterType === 'comment_added')) {
|
|
const comments = normalizeTaskComments(task.comments);
|
|
const flattenedComments = flattenTaskComments(comments);
|
|
|
|
flattenedComments.forEach(({ comment, path }) => {
|
|
const commentAuthorId = comment.commentAuthorId;
|
|
const normalizedCommentAuthorId = normalizeUserId(commentAuthorId);
|
|
const commentAuthorName = resolveUserName(
|
|
commentAuthorId,
|
|
userDirectory,
|
|
(normalizedCommentAuthorId
|
|
? taskUserNameById[normalizedCommentAuthorId] || globalTaskUserNameById[normalizedCommentAuthorId]
|
|
: null) ?? null
|
|
);
|
|
const commentAuthorAvatarUrl = resolveUserAvatarUrl(commentAuthorId, userDirectory);
|
|
if (isDebug && (commentAuthorName === "User" || commentAuthorName === "Unknown")) {
|
|
console.log("[activity-feed] comment author fallback", {
|
|
taskId: task.id,
|
|
commentId: comment.id,
|
|
path,
|
|
commentAuthorId,
|
|
taskFallbackName: taskUserNameById[commentAuthorId] ?? null,
|
|
resolvedName: commentAuthorName,
|
|
});
|
|
}
|
|
|
|
activityItems.push({
|
|
id: `${task.id}-comment-${comment.id}-${path}`,
|
|
type: 'comment_added',
|
|
task_id: task.id,
|
|
task_title: task.title,
|
|
project_id: task.project_id,
|
|
project_name: project?.name || 'Unknown Project',
|
|
project_color: project?.color || '#6B7280',
|
|
user_id: commentAuthorId,
|
|
user_name: commentAuthorName,
|
|
user_avatar_url: commentAuthorAvatarUrl,
|
|
timestamp: pickTimestamp(comment.createdAt, task.updated_at),
|
|
comment_text: comment.text,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sort by timestamp descending
|
|
activityItems.sort((a, b) =>
|
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
);
|
|
|
|
// Apply limit after all activities are collected
|
|
setActivities(activityItems.slice(0, limit));
|
|
if (isDebug) {
|
|
console.log("[activity-feed] activities prepared", {
|
|
totalActivities: activityItems.length,
|
|
returnedActivities: Math.min(activityItems.length, limit),
|
|
filterType,
|
|
projectId: projectId ?? null,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching activities:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to fetch activities');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [limit, projectId, filterType]);
|
|
|
|
useEffect(() => {
|
|
fetchProjects();
|
|
fetchActivities();
|
|
}, [fetchProjects, fetchActivities]);
|
|
|
|
// Poll for updates every 30 seconds (since realtime WebSocket is disabled)
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
fetchActivities();
|
|
}, 30000); // 30 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [fetchActivities]);
|
|
|
|
// Note: Real-time subscription disabled due to WebSocket connection issues
|
|
// The activity feed uses regular HTTP polling instead (30s interval)
|
|
// To re-enable realtime, configure Supabase Realtime in your project settings
|
|
|
|
const refresh = useCallback(() => {
|
|
fetchActivities();
|
|
}, [fetchActivities]);
|
|
|
|
return {
|
|
activities,
|
|
projects,
|
|
loading,
|
|
error,
|
|
refresh,
|
|
};
|
|
}
|