feat: persist portal invitation locales
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export type AuthAuditEventType =
|
||||
| "invitation_accepted"
|
||||
| "invitation_accept_failed"
|
||||
| "client_access_disabled"
|
||||
| "client_access_enabled";
|
||||
| "client_access_enabled"
|
||||
| "client_locale_updated";
|
||||
|
||||
Reference in New Issue
Block a user