feat: implement Moving Motivators feature with session management, real-time event handling, and UI components for enhanced user experience
This commit is contained in:
339
src/services/moving-motivators.ts
Normal file
339
src/services/moving-motivators.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { ShareRole, MotivatorType } from '@prisma/client';
|
||||
|
||||
// ============================================
|
||||
// Moving Motivators Session CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getMotivatorSessionsByUserId(userId: string) {
|
||||
// Get owned sessions + shared sessions
|
||||
const [owned, shared] = await Promise.all([
|
||||
prisma.movingMotivatorsSession.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
cards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
prisma.mMSessionShare.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
session: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
cards: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Mark owned sessions and merge with shared
|
||||
const ownedWithRole = owned.map((s) => ({ ...s, isOwner: true as const, role: 'OWNER' as const }));
|
||||
const sharedWithRole = shared.map((s) => ({
|
||||
...s.session,
|
||||
isOwner: false as const,
|
||||
role: s.role,
|
||||
sharedAt: s.createdAt,
|
||||
}));
|
||||
|
||||
return [...ownedWithRole, ...sharedWithRole].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export async function getMotivatorSessionById(sessionId: string, userId: string) {
|
||||
// Check if user owns the session OR has it shared
|
||||
const session = await prisma.movingMotivatorsSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [
|
||||
{ userId }, // Owner
|
||||
{ shares: { some: { userId } } }, // Shared with user
|
||||
],
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
cards: {
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
// Determine user's role
|
||||
const isOwner = session.userId === userId;
|
||||
const share = session.shares.find((s) => s.userId === userId);
|
||||
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
|
||||
const canEdit = isOwner || role === 'EDITOR';
|
||||
|
||||
return { ...session, isOwner, role, canEdit };
|
||||
}
|
||||
|
||||
// Check if user can access session (owner or shared)
|
||||
export async function canAccessMotivatorSession(sessionId: string, userId: string) {
|
||||
const count = await prisma.movingMotivatorsSession.count({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [{ userId }, { shares: { some: { userId } } }],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
// Check if user can edit session (owner or EDITOR role)
|
||||
export async function canEditMotivatorSession(sessionId: string, userId: string) {
|
||||
const count = await prisma.movingMotivatorsSession.count({
|
||||
where: {
|
||||
id: sessionId,
|
||||
OR: [{ userId }, { shares: { some: { userId, role: 'EDITOR' } } }],
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
const DEFAULT_MOTIVATOR_TYPES: MotivatorType[] = [
|
||||
'STATUS',
|
||||
'POWER',
|
||||
'ORDER',
|
||||
'ACCEPTANCE',
|
||||
'HONOR',
|
||||
'MASTERY',
|
||||
'SOCIAL',
|
||||
'FREEDOM',
|
||||
'CURIOSITY',
|
||||
'PURPOSE',
|
||||
];
|
||||
|
||||
export async function createMotivatorSession(
|
||||
userId: string,
|
||||
data: { title: string; participant: string }
|
||||
) {
|
||||
// Create session with all 10 cards initialized
|
||||
return prisma.movingMotivatorsSession.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
cards: {
|
||||
create: DEFAULT_MOTIVATOR_TYPES.map((type, index) => ({
|
||||
type,
|
||||
orderIndex: index + 1,
|
||||
influence: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
cards: {
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMotivatorSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
data: { title?: string; participant?: string }
|
||||
) {
|
||||
return prisma.movingMotivatorsSession.updateMany({
|
||||
where: { id: sessionId, userId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMotivatorSession(sessionId: string, userId: string) {
|
||||
return prisma.movingMotivatorsSession.deleteMany({
|
||||
where: { id: sessionId, userId },
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Motivator Cards CRUD
|
||||
// ============================================
|
||||
|
||||
export async function updateMotivatorCard(
|
||||
cardId: string,
|
||||
data: { orderIndex?: number; influence?: number }
|
||||
) {
|
||||
return prisma.motivatorCard.update({
|
||||
where: { id: cardId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderMotivatorCards(
|
||||
sessionId: string,
|
||||
cardIds: string[]
|
||||
) {
|
||||
const updates = cardIds.map((id, index) =>
|
||||
prisma.motivatorCard.update({
|
||||
where: { id },
|
||||
data: { orderIndex: index + 1 },
|
||||
})
|
||||
);
|
||||
|
||||
return prisma.$transaction(updates);
|
||||
}
|
||||
|
||||
export async function updateCardInfluence(cardId: string, influence: number) {
|
||||
// Clamp influence between -3 and +3
|
||||
const clampedInfluence = Math.max(-3, Math.min(3, influence));
|
||||
return prisma.motivatorCard.update({
|
||||
where: { id: cardId },
|
||||
data: { influence: clampedInfluence },
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Sharing
|
||||
// ============================================
|
||||
|
||||
export async function shareMotivatorSession(
|
||||
sessionId: string,
|
||||
ownerId: string,
|
||||
targetEmail: string,
|
||||
role: ShareRole = 'EDITOR'
|
||||
) {
|
||||
// Verify owner
|
||||
const session = await prisma.movingMotivatorsSession.findFirst({
|
||||
where: { id: sessionId, userId: ownerId },
|
||||
});
|
||||
if (!session) {
|
||||
throw new Error('Session not found or not owned');
|
||||
}
|
||||
|
||||
// Find target user
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { email: targetEmail },
|
||||
});
|
||||
if (!targetUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Can't share with yourself
|
||||
if (targetUser.id === ownerId) {
|
||||
throw new Error('Cannot share session with yourself');
|
||||
}
|
||||
|
||||
// Create or update share
|
||||
return prisma.mMSessionShare.upsert({
|
||||
where: {
|
||||
sessionId_userId: { sessionId, userId: targetUser.id },
|
||||
},
|
||||
update: { role },
|
||||
create: {
|
||||
sessionId,
|
||||
userId: targetUser.id,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMotivatorShare(
|
||||
sessionId: string,
|
||||
ownerId: string,
|
||||
shareUserId: string
|
||||
) {
|
||||
// Verify owner
|
||||
const session = await prisma.movingMotivatorsSession.findFirst({
|
||||
where: { id: sessionId, userId: ownerId },
|
||||
});
|
||||
if (!session) {
|
||||
throw new Error('Session not found or not owned');
|
||||
}
|
||||
|
||||
return prisma.mMSessionShare.deleteMany({
|
||||
where: { sessionId, userId: shareUserId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMotivatorSessionShares(sessionId: string, userId: string) {
|
||||
// Verify access
|
||||
if (!(await canAccessMotivatorSession(sessionId, userId))) {
|
||||
throw new Error('Access denied');
|
||||
}
|
||||
|
||||
return prisma.mMSessionShare.findMany({
|
||||
where: { sessionId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Events (for real-time sync)
|
||||
// ============================================
|
||||
|
||||
export type MMSessionEventType =
|
||||
| 'CARD_MOVED'
|
||||
| 'CARD_INFLUENCE_CHANGED'
|
||||
| 'CARDS_REORDERED'
|
||||
| 'SESSION_UPDATED';
|
||||
|
||||
export async function createMotivatorSessionEvent(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
type: MMSessionEventType,
|
||||
payload: Record<string, unknown>
|
||||
) {
|
||||
return prisma.mMSessionEvent.create({
|
||||
data: {
|
||||
sessionId,
|
||||
userId,
|
||||
type,
|
||||
payload: JSON.stringify(payload),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMotivatorSessionEvents(sessionId: string, since?: Date) {
|
||||
return prisma.mMSessionEvent.findMany({
|
||||
where: {
|
||||
sessionId,
|
||||
...(since && { createdAt: { gt: since } }),
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestMotivatorEventTimestamp(sessionId: string) {
|
||||
const event = await prisma.mMSessionEvent.findFirst({
|
||||
where: { sessionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
return event?.createdAt;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ export async function getSessionsByUserId(userId: string) {
|
||||
where: { userId },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
@@ -27,6 +32,11 @@ export async function getSessionsByUserId(userId: string) {
|
||||
session: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
shares: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
items: true,
|
||||
|
||||
Reference in New Issue
Block a user