feat: persist portal invitation locales

This commit is contained in:
poyrazavsever
2026-07-19 03:07:35 +03:00
parent c1c473469a
commit 0158f2f25a
10 changed files with 249 additions and 46 deletions
+99 -4
View File
@@ -12,9 +12,11 @@ import {
appProfiles,
authAuditEvents,
clients,
instanceLocales,
portalInvitations,
session,
user,
userPreferences,
} from "@/server/db/schema";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
@@ -23,6 +25,7 @@ const DEFAULT_INVITATION_TTL_HOURS = 72;
const createInvitationSchema = z.object({
clientId: z.string().trim().min(1).max(128),
email: z.email().transform(normalizeAuthEmail),
locale: z.string().trim().min(2).max(12).default("tr"),
expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS),
});
@@ -56,12 +59,13 @@ export type PortalInvitationPreview = {
email: string;
expiresAt: Date;
status: "pending" | "accepted" | "revoked" | "expired";
locale: string;
};
export async function createPortalInvitation(
actor: SessionContext,
input: z.input<typeof createInvitationSchema>,
): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> {
): Promise<{ id: number; invitationUrl: string; expiresAt: Date; locale: string }> {
assertFreelancerActor(actor);
const parsed = parseOrThrow(createInvitationSchema, input);
@@ -83,6 +87,7 @@ export async function createPortalInvitation(
if (!client) {
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
}
const locale = assertPortalReadyLocale(tx, parsed.locale);
if (client.authUserId) {
throw new PortalInvitationError(
@@ -143,6 +148,7 @@ export async function createPortalInvitation(
tokenHash,
clientId: parsed.clientId,
email: parsed.email,
locale,
status: "pending",
expiresAt,
createdByUserId: actor.user.id,
@@ -159,6 +165,7 @@ export async function createPortalInvitation(
metadata: {
invitationId: inserted.id,
clientId: parsed.clientId,
locale,
expiresAt: expiresAt.toISOString(),
},
})
@@ -171,6 +178,7 @@ export async function createPortalInvitation(
id: invitationId,
invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`,
expiresAt,
locale: parsed.locale,
};
}
@@ -186,6 +194,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr
email: portalInvitations.email,
status: portalInvitations.status,
expiresAt: portalInvitations.expiresAt,
locale: portalInvitations.locale,
})
.from(portalInvitations)
.where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken)))
@@ -214,7 +223,7 @@ export function getPortalInvitationPreview(rawToken: string): PortalInvitationPr
.values({
type: "invitation_expired",
email: invitation.email,
metadata: { invitationId: invitation.id },
metadata: { invitationId: invitation.id },
})
.run();
}
@@ -356,7 +365,7 @@ export async function acceptPortalInvitation(input: {
const linkedClient = tx
.update(clients)
.set({ authUserId, updatedAt: now })
.set({ authUserId, portalLocale: invitation.locale, updatedAt: now })
.where(
and(
eq(clients.id, invitation.clientId),
@@ -372,6 +381,20 @@ export async function acceptPortalInvitation(input: {
);
}
tx.insert(userPreferences)
.values({
ownerUserId: authUserId,
language: invitation.locale,
})
.onConflictDoUpdate({
target: userPreferences.ownerUserId,
set: {
language: invitation.locale,
updatedAt: now.toISOString(),
},
})
.run();
const accepted = tx
.update(portalInvitations)
.set({ status: "accepted", acceptedAt: now })
@@ -395,7 +418,7 @@ export async function acceptPortalInvitation(input: {
type: "invitation_accepted",
authUserId,
email: invitation.email,
metadata: { invitationId: invitation.id, clientId: invitation.clientId },
metadata: { invitationId: invitation.id, clientId: invitation.clientId, locale: invitation.locale },
})
.run();
@@ -497,6 +520,60 @@ export function setClientPortalAccess(
});
}
export function setClientPortalLocale(
actor: SessionContext,
clientId: string,
localeInput: string,
): { locale: string } {
assertFreelancerActor(actor);
const { db } = getSqliteConnection();
return db.transaction((tx) => {
const ownedClient = tx
.select({ id: clients.id, authUserId: clients.authUserId })
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.ownerUserId, actor.user.id)))
.get();
if (!ownedClient) {
throw new PortalInvitationError("CLIENT_NOT_FOUND", "Müşteri bulunamadı.");
}
const locale = assertPortalReadyLocale(tx, localeInput);
tx.update(clients)
.set({ portalLocale: locale, updatedAt: new Date() })
.where(eq(clients.id, clientId))
.run();
if (ownedClient.authUserId) {
tx.insert(userPreferences)
.values({
ownerUserId: ownedClient.authUserId,
language: locale,
})
.onConflictDoUpdate({
target: userPreferences.ownerUserId,
set: {
language: locale,
updatedAt: new Date().toISOString(),
},
})
.run();
tx.delete(session).where(eq(session.userId, ownedClient.authUserId)).run();
}
tx.insert(authAuditEvents)
.values({
type: "client_locale_updated",
authUserId: actor.user.id,
metadata: { clientId, locale, targetAuthUserId: ownedClient.authUserId },
})
.run();
return { locale };
});
}
export function hashInvitationToken(rawToken: string): string {
return createHash("sha256").update(rawToken, "utf8").digest("hex");
}
@@ -534,3 +611,21 @@ async function recordInvitationFailure(email: string | null, reason: string): Pr
})
.run();
}
function assertPortalReadyLocale(
tx: Pick<ReturnType<typeof getSqliteConnection>["db"], "select">,
localeInput: string,
): string {
const locale = localeInput.trim();
const row = tx
.select({ code: instanceLocales.code, status: instanceLocales.status })
.from(instanceLocales)
.where(eq(instanceLocales.code, locale))
.get();
if (!row || row.status !== "active") {
throw new PortalInvitationError("INVALID_INPUT", "Portal dili aktif değil.");
}
return row.code;
}
+2 -1
View File
@@ -22,4 +22,5 @@ export type AuthAuditEventType =
| "invitation_accepted"
| "invitation_accept_failed"
| "client_access_disabled"
| "client_access_enabled";
| "client_access_enabled"
| "client_locale_updated";