mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
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>
351 lines
8.5 KiB
TypeScript
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);
|
|
}
|
|
}
|