Skip to content

Commit d079d9f

Browse files
author
Joao Kronos
committed
feat: implement Phase 2 - kiosk, guest temporary lifecycle automation, and Google Calendar service account sharing
1 parent 5ba032f commit d079d9f

10 files changed

Lines changed: 1144 additions & 601 deletions

File tree

CHECKLIST_DIA_2026-06-12.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,16 @@
5252
- **Pago / Em Revisão**: Baseado no status atual do acerto de contas (`SettlementStatus`).
5353
- [ ] **Fluxo de Liquidação por PIX (Rateio)**: Exibir a chave PIX e recebedor do estúdio (`Workspace.pixKey` e `Workspace.pixRecipient`) na interface do artista, permitindo que ele selecione seus agendamentos concluídos, veja a divisão exata gerada pelo sistema, realize a transferência da porcentagem correspondente ao estúdio (PIX de rateio), anexe o comprovante de transferência diretamente no painel de acertos (`Settlements`) e aguarde a validação administrativa (`Aprovar/Rejeitar/Disputa`) no painel de controle do Admin para quitação do repasse.
5454

55-
### 🚀 PLANEJAMENTO DE IMPLEMENTAÇÃO: GUEST E KIOSK SIMPLIFICADO
56-
- [ ] **Kiosk Simplificado (Mobile-First)**:
55+
### ✅ IMPLEMENTAÇÃO DE FASE 2: GUEST E KIOSK SIMPLIFICADO
56+
- [x] **Kiosk Simplificado (Mobile-First)**:
5757
- Rota `/kiosk` pública para agendamento direto de clientes sem login.
5858
- Fluxo em 3 passos: 1. Dados Pessoais (Nome obrigatório, Insta ou Tel como contato); 2. Seleção de Dia e Horários Disponíveis; 3. Detalhes e confirmação.
5959
- Server Action `createKioskBooking` criando agendamento com status `OPEN` (para confirmação posterior do artista/admin).
60-
- [ ] **Automação de Guest Temporário (Prisma e Cron)**:
60+
- [x] **Automação de Guest Temporário (Prisma e Cron)**:
6161
- Aproveitar a infraestrutura existente do banco: tabela `Artist` com `plan = GUEST` e `validUntil` como data de expiração, atrelada ao convite com `durationDays`.
6262
- Cron Job diário (`/api/cron/check-expired-guests`) que busca artistas ativos com `plan = GUEST` e `validUntil < hoje`.
6363
- Ações do Cron: Mudar `Artist.isActive = false`, revogar/apagar a membership no workspace, e enviar o e-mail de encerramento, mantendo o histórico de agendamentos no banco para fins de rateio e histórico financeiro.
64-
- [ ] **Google Calendar Automático (Service Account)**:
64+
- [x] **Google Calendar Automático (Service Account)**:
6565
- Configurar Service Account do Google Cloud Platform com chaves `GOOGLE_SERVICE_ACCOUNT_EMAIL` e `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY`.
6666
- Criar helper `shareCalendarWithUser` para compartilhar de forma automatizada o calendário do estúdio com o e-mail do artista convidado (permissão `writer`) no momento do aceite do convite.
6767
- Criar helper `removeCalendarShare` para revogar o acesso à agenda no Google Calendar no momento da expiração do contrato do Guest.

kronos/src/app/actions/kiosk.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
'use server'
2+
3+
import { prisma } from "@/lib/prisma"
4+
import { z } from "zod"
5+
import { revalidatePath } from "next/cache"
6+
7+
// Schema validation for Kiosk Booking
8+
const kioskBookingSchema = z.object({
9+
name: z.string().min(2, "Nome é obrigatório (mínimo 2 caracteres)"),
10+
phone: z.string().optional(),
11+
instagram: z.string().optional(),
12+
artistId: z.string().min(1, "Selecione um artista"),
13+
scheduledFor: z.string().refine((val) => !isNaN(Date.parse(val)), "Data inválida"),
14+
duration: z.number().min(30, "Duração mínima é de 30 minutos").max(480, "Duração máxima de 8 horas"),
15+
type: z.string().min(2, "Tipo de trabalho é obrigatório"),
16+
notes: z.string().optional(),
17+
honeypot: z.string().optional(),
18+
}).refine((data) => {
19+
const hasPhone = data.phone && data.phone.trim().length > 0;
20+
const hasInstagram = data.instagram && data.instagram.trim().length > 0;
21+
return hasPhone || hasInstagram;
22+
}, {
23+
message: "Forneça pelo menos um meio de contato: Instagram ou Telefone",
24+
path: ["instagram"],
25+
});
26+
27+
/**
28+
* Retrieve all active artists associated with a workspace for the Kiosk selection.
29+
*/
30+
export async function getKioskArtists() {
31+
try {
32+
const artists = await prisma.artist.findMany({
33+
where: {
34+
isActive: true,
35+
workspaceId: { not: null }
36+
},
37+
include: {
38+
user: {
39+
select: {
40+
name: true,
41+
image: true,
42+
}
43+
},
44+
workspace: {
45+
select: {
46+
id: true,
47+
name: true,
48+
primaryColor: true,
49+
}
50+
}
51+
},
52+
orderBy: {
53+
user: {
54+
name: 'asc'
55+
}
56+
}
57+
})
58+
59+
return { success: true, artists }
60+
} catch (error) {
61+
console.error('Error fetching kiosk artists:', error)
62+
return { success: false, error: 'Erro ao buscar artistas do estúdio' }
63+
}
64+
}
65+
66+
/**
67+
* Calculates available time slots for a specific artist on a given date.
68+
* Business hours: 09:00 to 20:00 (America/Sao_Paulo).
69+
*/
70+
export async function getKioskAvailableSlots(artistId: string, dateStr: string, durationMin: number) {
71+
try {
72+
if (!artistId || !dateStr) {
73+
return { success: false, error: 'Dados insuficientes' }
74+
}
75+
76+
const artist = await prisma.artist.findUnique({
77+
where: { id: artistId },
78+
include: { workspace: true }
79+
})
80+
81+
if (!artist || !artist.workspaceId || !artist.workspace) {
82+
return { success: false, error: 'Artista ou Workspace não encontrado' }
83+
}
84+
85+
const workspace = artist.workspace
86+
const capacity = workspace.capacity || 3
87+
88+
// Fetch all bookings for the artist on the selected date
89+
const dayStart = new Date(`${dateStr}T00:00:00-03:00`)
90+
const dayEnd = new Date(`${dateStr}T23:59:59-03:00`)
91+
92+
// All active bookings for the artist today
93+
const artistBookings = await prisma.booking.findMany({
94+
where: {
95+
artistId,
96+
status: { in: ['OPEN', 'CONFIRMED', 'COMPLETED'] },
97+
scheduledFor: {
98+
gte: dayStart,
99+
lte: dayEnd
100+
}
101+
},
102+
include: {
103+
slot: true
104+
}
105+
})
106+
107+
// All active slots in the workspace today to check studio capacity
108+
const workspaceSlots = await prisma.slot.findMany({
109+
where: {
110+
workspaceId: artist.workspaceId,
111+
isActive: true,
112+
startTime: {
113+
gte: dayStart,
114+
lte: dayEnd
115+
}
116+
}
117+
})
118+
119+
// Business Hours slots: 09:00 to 19:00 hourly
120+
const hours = ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00']
121+
const availableSlots: string[] = []
122+
123+
for (const hr of hours) {
124+
const slotStart = new Date(`${dateStr}T${hr}:00-03:00`)
125+
const slotEnd = new Date(slotStart.getTime() + durationMin * 60 * 1000)
126+
127+
// 1. Check if artist has a conflict
128+
const hasArtistConflict = artistBookings.some((booking) => {
129+
const bStart = new Date(booking.scheduledFor)
130+
const bEnd = new Date(bStart.getTime() + booking.duration * 60 * 1000)
131+
return slotStart < bEnd && slotEnd > bStart
132+
})
133+
134+
if (hasArtistConflict) continue
135+
136+
// 2. Check if workspace capacity is exceeded
137+
const activeSlotsInInterval = workspaceSlots.filter((slot) => {
138+
const sStart = new Date(slot.startTime)
139+
const sEnd = new Date(slot.endTime)
140+
return slotStart < sEnd && slotEnd > sStart
141+
})
142+
143+
if (activeSlotsInInterval.length >= capacity) continue
144+
145+
// If no conflict, slot is free
146+
availableSlots.push(hr)
147+
}
148+
149+
return { success: true, slots: availableSlots }
150+
} catch (error) {
151+
console.error('Error calculating available slots:', error)
152+
return { success: false, error: 'Erro ao calcular horários livres' }
153+
}
154+
}
155+
156+
/**
157+
* Creates a booking request from the public Kiosk form.
158+
*/
159+
export async function createKioskBooking(rawData: any) {
160+
// Anti-bot Protection: Honeypot field
161+
if (rawData.honeypot && rawData.honeypot.trim().length > 0) {
162+
console.warn('🤖 Bot detected in honeypot field. Silent rejection.')
163+
return { success: true, message: 'Agendamento recebido, aguardando confirmação' }
164+
}
165+
166+
// Zod Validation
167+
const result = kioskBookingSchema.safeParse(rawData)
168+
if (!result.success) {
169+
return { success: false, error: result.error.issues[0].message }
170+
}
171+
172+
const data = result.data
173+
const phone = data.phone?.trim()
174+
const instagram = data.instagram?.trim()
175+
176+
try {
177+
// 1. Look up artist and workspace details
178+
const artist = await prisma.artist.findUnique({
179+
where: { id: data.artistId },
180+
include: { workspace: true, user: true }
181+
})
182+
183+
if (!artist || !artist.workspaceId) {
184+
return { success: false, error: 'Artista selecionado não possui estúdio associado.' }
185+
}
186+
187+
// 2. Find or Create Client
188+
let client = null
189+
190+
// Match criteria: Telefone or email derived from Instagram
191+
const instagramEmail = instagram ? `${instagram.replace('@', '').toLowerCase()}@instagram.com` : undefined
192+
193+
if (phone) {
194+
client = await prisma.user.findFirst({
195+
where: { phone, role: 'CLIENT' }
196+
})
197+
}
198+
199+
if (!client && instagramEmail) {
200+
client = await prisma.user.findFirst({
201+
where: { email: instagramEmail, role: 'CLIENT' }
202+
})
203+
}
204+
205+
if (!client) {
206+
// Create a sovereign CLIENT user profile
207+
client = await prisma.user.create({
208+
data: {
209+
name: data.name,
210+
phone: phone || null,
211+
email: instagramEmail || null,
212+
role: 'CLIENT'
213+
}
214+
})
215+
console.log(`✅ [Kiosk] Novo cliente cadastrado: ${client.name}`)
216+
}
217+
218+
// 3. Allocate a free table/maca in the workspace
219+
const scheduledStart = new Date(data.scheduledFor)
220+
const scheduledEnd = new Date(scheduledStart.getTime() + data.duration * 60 * 1000)
221+
222+
const workspaceSlots = await prisma.slot.findMany({
223+
where: {
224+
workspaceId: artist.workspaceId,
225+
isActive: true,
226+
startTime: { lt: scheduledEnd },
227+
endTime: { gt: scheduledStart }
228+
}
229+
})
230+
231+
const capacity = artist.workspace?.capacity || 3
232+
const occupiedMacas = new Set(workspaceSlots.map(s => s.macaId))
233+
234+
let allocatedMacaId = null
235+
for (let m = 1; m <= capacity; m++) {
236+
if (!occupiedMacas.has(m)) {
237+
allocatedMacaId = m
238+
break
239+
}
240+
}
241+
242+
if (!allocatedMacaId) {
243+
return { success: false, error: 'Não há macas disponíveis no horário escolhido. Por favor, selecione outro horário.' }
244+
}
245+
246+
// Create the Slot
247+
const slot = await prisma.slot.create({
248+
data: {
249+
workspaceId: artist.workspaceId,
250+
macaId: allocatedMacaId,
251+
startTime: scheduledStart,
252+
endTime: scheduledEnd,
253+
isActive: true
254+
}
255+
})
256+
257+
// Create the Booking
258+
// status: OPEN, value: 0 (artist defines rate later)
259+
const booking = await prisma.booking.create({
260+
data: {
261+
artistId: artist.id,
262+
clientId: client.id,
263+
workspaceId: artist.workspaceId,
264+
slotId: slot.id,
265+
value: 0,
266+
discountValue: 0,
267+
finalValue: 0,
268+
studioShare: 0,
269+
artistShare: 0,
270+
status: 'OPEN',
271+
type: data.type,
272+
scheduledFor: scheduledStart,
273+
duration: data.duration,
274+
notes: data.notes || '',
275+
syncedToGoogle: false
276+
}
277+
})
278+
279+
console.log(`✅ [Kiosk] Agendamento criado com sucesso. ID: ${booking.id}`)
280+
281+
// Create Kiosk Entry logging
282+
await prisma.kioskEntry.create({
283+
data: {
284+
workspaceId: artist.workspaceId,
285+
name: data.name,
286+
phone: phone || null,
287+
instagram: instagram || null,
288+
type: 'CLIENT',
289+
intent: data.type,
290+
artistId: artist.id,
291+
marketingOptIn: true
292+
}
293+
})
294+
295+
revalidatePath('/artist/agenda')
296+
297+
return { success: true, message: 'Agendamento recebido, aguardando confirmação' }
298+
} catch (error: any) {
299+
console.error('Error creating Kiosk booking:', error)
300+
return { success: false, error: error.message || 'Erro ao processar agendamento' }
301+
}
302+
}

kronos/src/app/actions/workspaces.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export async function revokeArtistAccess(artistId: string) {
353353
// 2. Buscar o artista para garantir que ele pertence a este workspace
354354
const targetArtist = await prisma.artist.findUnique({
355355
where: { id: artistId },
356-
include: { user: true }
356+
include: { user: true, workspace: true }
357357
})
358358

359359
if (!targetArtist || targetArtist.workspaceId !== activeWorkspaceId) {
@@ -386,6 +386,16 @@ export async function revokeArtistAccess(artistId: string) {
386386
})
387387
])
388388

389+
// Revogar acesso do Google Calendar se aplicável
390+
if (targetArtist.workspace?.googleCalendarId && targetArtist.user.email) {
391+
try {
392+
const { removeCalendarShare } = await import('@/lib/google-admin')
393+
await removeCalendarShare(targetArtist.workspace.googleCalendarId, targetArtist.user.email)
394+
} catch (calErr) {
395+
console.error('[revokeArtistAccess] Erro ao revogar Google Calendar do artista:', calErr)
396+
}
397+
}
398+
389399
revalidatePath('/artist/team')
390400
return { success: true, message: `Acesso de ${targetArtist.user.name} revogado com sucesso.` }
391401

kronos/src/app/api/auth/redeem-invite/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ export async function POST(req: NextRequest) {
105105
}
106106
})
107107
}
108+
109+
// Compartilhamento automático do Google Calendar
110+
if (targetRole === 'ARTIST' && invite.workspace?.googleCalendarId && user.email) {
111+
try {
112+
const { shareCalendarWithUser } = await import('@/lib/google-admin')
113+
await shareCalendarWithUser(invite.workspace.googleCalendarId, user.email)
114+
} catch (calErr) {
115+
console.error('[redeem-invite] Falha ao compartilhar calendário do estúdio:', calErr)
116+
}
117+
}
108118
}
109119

110120
// 5. Atualizar metadata do Clerk imediatamente para liberar o login do artista

0 commit comments

Comments
 (0)