Files
openmonetis/src/features/payers/actions.ts
Felipe Coutinho 39f3cd8b20 feat(payers): gerar share_code na aplicação e remover pgcrypto
Move a geração do share_code do PostgreSQL para a camada de aplicação,
eliminando a dependência da extensão pgcrypto no setup do banco.

- schema: drop default substr(encode(gen_random_bytes(24), 'base64'), 1, 24)
  da coluna share_code em pagadores (continua NOT NULL)
- nova util generateShareCode() em shared/lib/payers/share-code.ts
  (server-only, usa crypto.randomBytes do Node)
- chamadas explícitas em createPayerAction, ensureDefaultPagadorForUser,
  resetUserAppData e mock-data ao inserir pagadores
- migration 0028_fancy_reaper renumerada (0027 já estava ocupado por
  arquivo órfão); journal e snapshot atualizados
- remove etapa de habilitação de pgcrypto do docker-entrypoint.sh
- remove scripts/postgres/ (init.sql e enable-extensions.ts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:52:36 +00:00

351 lines
8.5 KiB
TypeScript

"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { payerShares, payers, user } from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
DEFAULT_PAYER_AVATAR,
PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY,
PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants";
import { generateShareCode } from "@/shared/lib/payers/share-code";
import { normalizeAvatarPath } from "@/shared/lib/payers/utils";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
import { normalizeOptionalString } from "@/shared/utils/string";
const statusEnum = z
.enum([...PAYER_STATUS_OPTIONS] as [string, ...string[]])
.refine(
(v) =>
PAYER_STATUS_OPTIONS.includes(v as (typeof PAYER_STATUS_OPTIONS)[number]),
{
message: "Selecione um status válido.",
},
);
const baseSchema = z.object({
name: z
.string({ message: "Informe o nome da pessoa." })
.trim()
.min(1, "Informe o nome da pessoa."),
email: z
.string()
.trim()
.email("Informe um e-mail válido.")
.nullish()
.transform((value) => normalizeOptionalString(value)),
status: statusEnum,
note: noteSchema,
avatarUrl: z.string().trim().optional(),
isAutoSend: z.boolean().optional().default(false),
});
const createSchema = baseSchema;
const updateSchema = baseSchema.extend({
id: uuidSchema("Payer"),
});
const deleteSchema = z.object({
id: uuidSchema("Payer"),
});
const shareDeleteSchema = z.object({
shareId: uuidSchema("Compartilhamento"),
});
const shareCodeJoinSchema = z.object({
code: z
.string({ message: "Informe o código." })
.trim()
.min(8, "Código inválido."),
});
const shareCodeRegenerateSchema = z.object({
payerId: uuidSchema("Payer"),
});
type CreateInput = z.infer<typeof createSchema>;
type UpdateInput = z.infer<typeof updateSchema>;
type DeleteInput = z.infer<typeof deleteSchema>;
type ShareDeleteInput = z.infer<typeof shareDeleteSchema>;
type ShareCodeJoinInput = z.infer<typeof shareCodeJoinSchema>;
type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
const revalidate = (userId: string) => revalidateForEntity("payers", userId);
export async function createPayerAction(
input: CreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createSchema.parse(input);
await db.insert(payers).values({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAYER_AVATAR,
isAutoSend: data.isAutoSend ?? false,
role: PAYER_ROLE_THIRD_PARTY,
shareCode: generateShareCode(),
userId: user.id,
});
revalidate(user.id);
return { success: true, message: "Pessoa criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updatePayerAction(
input: UpdateInput,
): Promise<ActionResult> {
try {
const currentUser = await getUser();
const data = updateSchema.parse(input);
const existing = await db.query.payers.findFirst({
where: and(eq(payers.id, data.id), eq(payers.userId, currentUser.id)),
});
if (!existing) {
return {
success: false,
error: "Pessoa não encontrada.",
};
}
await db
.update(payers)
.set({
name: data.name,
email: data.email,
status: data.status,
note: data.note,
avatarUrl:
normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null,
isAutoSend: data.isAutoSend ?? false,
role: existing.role ?? PAYER_ROLE_THIRD_PARTY,
})
.where(and(eq(payers.id, data.id), eq(payers.userId, currentUser.id)));
// Se o pagador é admin, sincronizar nome com o usuário
if (existing.role === PAYER_ROLE_ADMIN) {
await db
.update(user)
.set({ name: data.name })
.where(eq(user.id, currentUser.id));
revalidatePath("/", "layout");
}
revalidate(currentUser.id);
return { success: true, message: "Pessoa atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deletePayerAction(
input: DeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteSchema.parse(input);
const existing = await db.query.payers.findFirst({
where: and(eq(payers.id, data.id), eq(payers.userId, user.id)),
});
if (!existing) {
return {
success: false,
error: "Pessoa não encontrada.",
};
}
if (existing.role === PAYER_ROLE_ADMIN) {
return {
success: false,
error: "Pessoas administradoras não podem ser removidas.",
};
}
await db
.delete(payers)
.where(and(eq(payers.id, data.id), eq(payers.userId, user.id)));
revalidate(user.id);
return { success: true, message: "Pessoa removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function joinPayerByShareCodeAction(
input: ShareCodeJoinInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = shareCodeJoinSchema.parse(input);
const pagadorRow = await db.query.payers.findFirst({
where: eq(payers.shareCode, data.code),
});
if (!pagadorRow) {
return { success: false, error: "Código inválido ou expirado." };
}
if (pagadorRow.userId === user.id) {
return {
success: false,
error: "Você já é o proprietário desta entidade pagadora.",
};
}
const existingShare = await db.query.payerShares.findFirst({
where: and(
eq(payerShares.payerId, pagadorRow.id),
eq(payerShares.sharedWithUserId, user.id),
),
});
if (existingShare) {
return {
success: false,
error: "Você já possui acesso a esta pessoa.",
};
}
await db.insert(payerShares).values({
payerId: pagadorRow.id,
sharedWithUserId: user.id,
permission: "read",
createdByUserId: pagadorRow.userId,
});
revalidate(user.id);
return { success: true, message: "Pessoa adicionada à sua lista." };
} catch (error) {
return handleActionError(error);
}
}
export async function deletePayerShareAction(
input: ShareDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = shareDeleteSchema.parse(input);
const existing = await db.query.payerShares.findFirst({
columns: {
id: true,
payerId: true,
sharedWithUserId: true,
},
where: eq(payerShares.id, data.shareId),
with: {
payer: {
columns: {
userId: true,
},
},
},
});
// Permitir que o owner OU o próprio usuário compartilhado remova o share
const payerOwner = existing?.payer as { userId: string } | null | undefined;
if (
!existing ||
(payerOwner?.userId !== user.id && existing.sharedWithUserId !== user.id)
) {
return {
success: false,
error: "Compartilhamento não encontrado.",
};
}
await db.delete(payerShares).where(eq(payerShares.id, data.shareId));
revalidate(user.id);
revalidatePath(`/payers/${existing.payerId}`);
return { success: true, message: "Compartilhamento removido." };
} catch (error) {
return handleActionError(error);
}
}
export async function regeneratePayerShareCodeAction(
input: ShareCodeRegenerateInput,
): Promise<{ success: true; message: string; code: string } | ActionResult> {
try {
const user = await getUser();
const data = shareCodeRegenerateSchema.parse(input);
const existing = await db.query.payers.findFirst({
columns: { id: true, userId: true },
where: and(eq(payers.id, data.payerId), eq(payers.userId, user.id)),
});
if (!existing) {
return { success: false, error: "Pessoa não encontrada." };
}
let attempts = 0;
while (attempts < 5) {
const newCode = generateShareCode();
try {
await db
.update(payers)
.set({ shareCode: newCode })
.where(and(eq(payers.id, data.payerId), eq(payers.userId, user.id)));
revalidate(user.id);
revalidatePath(`/payers/${data.payerId}`);
return {
success: true,
message: "Código atualizado com sucesso.",
code: newCode,
};
} catch (error) {
if (
error instanceof Error &&
"constraint" in error &&
(error as { constraint?: string }).constraint ===
"pagadores_share_code_key"
) {
attempts += 1;
continue;
}
throw error;
}
}
return {
success: false,
error: "Não foi possível gerar um código único. Tente novamente.",
};
} catch (error) {
return handleActionError(error);
}
}