Compare commits
5 Commits
feat/card-
...
fix/openap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafe4d0cd8 | ||
|
|
50382e9186 | ||
|
|
00be5fb058 | ||
|
|
fc65f53e0c | ||
|
|
64aace6e3b |
@@ -35,8 +35,10 @@ services:
|
|||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
- SMTP_SECURE=${SMTP_SECURE}
|
- SMTP_SECURE=${SMTP_SECURE}
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
- PLUNK_API_KEY=${PLUNK_API_KEY}
|
|
||||||
- PLUNK_API_URL=${PLUNK_API_URL}
|
# Notifications
|
||||||
|
- NOVU_API_KEY=${NOVU_API_KEY}
|
||||||
|
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
|
||||||
|
|
||||||
# S3 storage
|
# S3 storage
|
||||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||||
|
|||||||
@@ -476,7 +476,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Get invite information by code",
|
summary: "Get invite information by code",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
|
path: "/invites/{inviteCode}",
|
||||||
description: "Get invite information by invite code",
|
description: "Get invite information by invite code",
|
||||||
tags: ["Invites"],
|
tags: ["Invites"],
|
||||||
protect: false,
|
protect: false,
|
||||||
@@ -521,7 +521,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
openapi: {
|
openapi: {
|
||||||
summary: "Accept an invite link",
|
summary: "Accept an invite link",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/workspaces/{workspacePublicId}/invites/accept",
|
path: "/invites/accept",
|
||||||
description: "Accepts an invitation via invite link",
|
description: "Accepts an invitation via invite link",
|
||||||
tags: ["Invites"],
|
tags: ["Invites"],
|
||||||
protect: false,
|
protect: false,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { Subscription } from "@better-auth/stripe";
|
||||||
|
import type Stripe from "stripe";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { stripe } from "@better-auth/stripe";
|
import { stripe } from "@better-auth/stripe";
|
||||||
|
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||||
@@ -14,7 +17,7 @@ import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
|||||||
import * as userRepo from "@kan/db/repository/user.repo";
|
import * as userRepo from "@kan/db/repository/user.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import * as schema from "@kan/db/schema";
|
import * as schema from "@kan/db/schema";
|
||||||
import { cloudMailerClient, sendEmail } from "@kan/email";
|
import { notificationClient, sendEmail } from "@kan/email";
|
||||||
import { createStripeClient } from "@kan/stripe";
|
import { createStripeClient } from "@kan/stripe";
|
||||||
|
|
||||||
export const configuredProviders = socialProviderList.reduce<
|
export const configuredProviders = socialProviderList.reduce<
|
||||||
@@ -178,19 +181,17 @@ export const initAuth = (db: dbClient) => {
|
|||||||
freeTrial: {
|
freeTrial: {
|
||||||
days: 14,
|
days: 14,
|
||||||
onTrialStart: async (subscription) => {
|
onTrialStart: async (subscription) => {
|
||||||
// Called when a trial starts
|
await triggerWorkflow(db, "trial-start", subscription);
|
||||||
// @todo: send trial start email
|
|
||||||
// await sendTrialStartEmail(subscription.referenceId);
|
|
||||||
},
|
},
|
||||||
onTrialEnd: async ({ subscription }, request) => {
|
onTrialEnd: async ({ subscription }) => {
|
||||||
// Called when a trial ends
|
await triggerWorkflow(db, "trial-end", subscription);
|
||||||
// @todo: send trial end email
|
|
||||||
// await sendTrialEndEmail(user.email);
|
|
||||||
},
|
},
|
||||||
onTrialExpired: async (subscription) => {
|
onTrialExpired: async (subscription) => {
|
||||||
// Called when a trial expires without conversion
|
await triggerWorkflow(
|
||||||
// @todo: send trial expired email
|
db,
|
||||||
// await sendTrialExpiredEmail(subscription.referenceId);
|
"trial-expired",
|
||||||
|
subscription,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -202,19 +203,17 @@ export const initAuth = (db: dbClient) => {
|
|||||||
freeTrial: {
|
freeTrial: {
|
||||||
days: 14,
|
days: 14,
|
||||||
onTrialStart: async (subscription) => {
|
onTrialStart: async (subscription) => {
|
||||||
// Called when a trial starts
|
await triggerWorkflow(db, "trial-start", subscription);
|
||||||
// @todo: send trial start email
|
|
||||||
// await sendTrialStartEmail(subscription.referenceId);
|
|
||||||
},
|
},
|
||||||
onTrialEnd: async ({ subscription }, request) => {
|
onTrialEnd: async ({ subscription }) => {
|
||||||
// Called when a trial ends
|
await triggerWorkflow(db, "trial-end", subscription);
|
||||||
// @todo: send trial end email
|
|
||||||
// await sendTrialEndEmail(user.email);
|
|
||||||
},
|
},
|
||||||
onTrialExpired: async (subscription) => {
|
onTrialExpired: async (subscription) => {
|
||||||
// Called when a trial expires without conversion
|
await triggerWorkflow(
|
||||||
// @todo: send trial expired email
|
db,
|
||||||
// await sendTrialExpiredEmail(subscription.referenceId);
|
"trial-expired",
|
||||||
|
subscription,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -348,17 +347,7 @@ export const initAuth = (db: dbClient) => {
|
|||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
async after(user) {
|
async after(user) {
|
||||||
if (cloudMailerClient) {
|
let avatarKey = user.image;
|
||||||
await cloudMailerClient.events.track({
|
|
||||||
event: "user-signup",
|
|
||||||
email: user.email,
|
|
||||||
data: {
|
|
||||||
name: user.name,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user.image &&
|
user.image &&
|
||||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||||
@@ -391,6 +380,9 @@ export const initAuth = (db: dbClient) => {
|
|||||||
ACL: "public-read",
|
ACL: "public-read",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
avatarKey = key;
|
||||||
|
|
||||||
await userRepo.update(db, user.id, {
|
await userRepo.update(db, user.id, {
|
||||||
image: key,
|
image: key,
|
||||||
});
|
});
|
||||||
@@ -398,6 +390,51 @@ export const initAuth = (db: dbClient) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notificationClient) {
|
||||||
|
try {
|
||||||
|
const [firstName, ...rest] = user.name
|
||||||
|
.split(" ")
|
||||||
|
.filter(Boolean);
|
||||||
|
const lastName = rest.length ? rest.join(" ") : undefined;
|
||||||
|
const avatarUrl = avatarKey
|
||||||
|
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await notificationClient.trigger({
|
||||||
|
to: {
|
||||||
|
subscriberId: user.id,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
email: user.email,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
data: {
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
stripeCustomerId: user.stripeCustomerId,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflowId: "user-signup",
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationClient.subscribers.credentials.update(
|
||||||
|
{
|
||||||
|
providerId: ChatOrPushProviderEnum.Discord,
|
||||||
|
credentials: {
|
||||||
|
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
|
||||||
|
},
|
||||||
|
integrationIdentifier: "discord",
|
||||||
|
},
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Error adding user to notification client",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -435,3 +472,34 @@ export const initAuth = (db: dbClient) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function triggerWorkflow(
|
||||||
|
db: dbClient,
|
||||||
|
workflowId: string,
|
||||||
|
subscription: Subscription,
|
||||||
|
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!subscription.stripeCustomerId || !notificationClient) return;
|
||||||
|
|
||||||
|
const user = await userRepo.getByStripeCustomerId(
|
||||||
|
db,
|
||||||
|
subscription.stripeCustomerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user || !notificationClient) return;
|
||||||
|
|
||||||
|
await notificationClient.trigger({
|
||||||
|
to: {
|
||||||
|
subscriberId: user.id,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
...subscription,
|
||||||
|
cancellationDetails,
|
||||||
|
},
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error triggering workflow", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export const getById = async (db: dbClient, userId: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getByStripeCustomerId = async (
|
||||||
|
db: dbClient,
|
||||||
|
stripeCustomerId: string,
|
||||||
|
) => {
|
||||||
|
return await db.query.users.findFirst({
|
||||||
|
where: eq(users.stripeCustomerId, stripeCustomerId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getByEmail = (db: dbClient, email: string) => {
|
export const getByEmail = (db: dbClient, email: string) => {
|
||||||
return db.query.users.findFirst({
|
return db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plunk/node": "^3.0.3",
|
"@novu/api": "^3.11.0",
|
||||||
"@react-email/components": "^0.0.25",
|
"@react-email/components": "^0.0.25",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"react-email": "^3.0.1"
|
"react-email": "^3.0.1"
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import Plunk from "@plunk/node";
|
|
||||||
|
|
||||||
export const cloudMailerClient =
|
|
||||||
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" &&
|
|
||||||
process.env.PLUNK_API_KEY &&
|
|
||||||
process.env.PLUNK_API_URL
|
|
||||||
? new Plunk(process.env.PLUNK_API_KEY ?? "", {
|
|
||||||
baseUrl: process.env.PLUNK_API_URL,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const name = "email";
|
export const name = "email";
|
||||||
|
|
||||||
export { sendEmail } from "./sendEmail";
|
export { sendEmail } from "./sendEmail";
|
||||||
export { cloudMailerClient } from "./cloudMailerClient";
|
export { notificationClient } from "./notificationClient";
|
||||||
|
|||||||
6
packages/email/src/notificationClient.tsx
Normal file
6
packages/email/src/notificationClient.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Novu } from "@novu/api";
|
||||||
|
|
||||||
|
export const notificationClient =
|
||||||
|
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && process.env.NOVU_API_KEY
|
||||||
|
? new Novu({ secretKey: process.env.NOVU_API_KEY })
|
||||||
|
: null;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { render } from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
import { cloudMailerClient } from "./cloudMailerClient";
|
|
||||||
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
import JoinWorkspaceTemplate from "./templates/join-workspace";
|
||||||
import MagicLinkTemplate from "./templates/magic-link";
|
import MagicLinkTemplate from "./templates/magic-link";
|
||||||
import ResetPasswordTemplate from "./templates/reset-password";
|
import ResetPasswordTemplate from "./templates/reset-password";
|
||||||
@@ -45,15 +44,6 @@ export const sendEmail = async (
|
|||||||
html,
|
html,
|
||||||
};
|
};
|
||||||
|
|
||||||
// if (cloudMailerClient) {
|
|
||||||
// const response = await cloudMailerClient.emails.send({
|
|
||||||
// ...options,
|
|
||||||
// body: html,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return response;
|
|
||||||
// }
|
|
||||||
|
|
||||||
const response = await transporter.sendMail(options);
|
const response = await transporter.sendMail(options);
|
||||||
|
|
||||||
if (!response.accepted.length) {
|
if (!response.accepted.length) {
|
||||||
|
|||||||
@@ -71,4 +71,13 @@ export const updateSubscriptionSeats = async (
|
|||||||
return updatedSubscription;
|
return updatedSubscription;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCancellationDetails = async (
|
||||||
|
stripeSubscriptionId: string,
|
||||||
|
): Promise<Stripe.Subscription.CancellationDetails | null> => {
|
||||||
|
const stripe = createStripeClient();
|
||||||
|
const stripeSubscription =
|
||||||
|
await stripe.subscriptions.retrieve(stripeSubscriptionId);
|
||||||
|
return stripeSubscription.cancellation_details ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export { createStripeClient };
|
export { createStripeClient };
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -440,9 +440,9 @@ importers:
|
|||||||
|
|
||||||
packages/email:
|
packages/email:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@plunk/node':
|
'@novu/api':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.11.0
|
||||||
version: 3.0.3
|
version: 3.11.0
|
||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^0.0.25
|
specifier: ^0.0.25
|
||||||
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -2683,6 +2683,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@novu/api@3.11.0':
|
||||||
|
resolution: {integrity: sha512-8u0mB5VThL7MhdxoN0UoA4CS9eu2k3Xa6iulauMhCHENuJNxUNuirJWq5t8jDoH4bFTQTfMN0VRkC0qodKR2qA==}
|
||||||
|
|
||||||
'@octokit/auth-token@3.0.4':
|
'@octokit/auth-token@3.0.4':
|
||||||
resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==}
|
resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -2762,9 +2765,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@plunk/node@3.0.3':
|
|
||||||
resolution: {integrity: sha512-f3NT418/EdLjebQ4arAwR64cnz7d5H7i1CeKh8+B0cVBrwzHvF+Q+QkhutF+FAp49yuI/BYc71qQH+RFc2fxxQ==}
|
|
||||||
|
|
||||||
'@popperjs/core@2.11.8':
|
'@popperjs/core@2.11.8':
|
||||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
@@ -10226,6 +10226,10 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.19.1
|
fastq: 1.19.1
|
||||||
|
|
||||||
|
'@novu/api@3.11.0':
|
||||||
|
dependencies:
|
||||||
|
zod: 3.25.76
|
||||||
|
|
||||||
'@octokit/auth-token@3.0.4': {}
|
'@octokit/auth-token@3.0.4': {}
|
||||||
|
|
||||||
'@octokit/core@4.2.4':
|
'@octokit/core@4.2.4':
|
||||||
@@ -10345,10 +10349,6 @@ snapshots:
|
|||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@plunk/node@3.0.3':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
'@posthog/core@1.0.2': {}
|
'@posthog/core@1.0.2': {}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
"NEXT_PUBLIC_KAN_ENV",
|
"NEXT_PUBLIC_KAN_ENV",
|
||||||
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
||||||
"STRIPE_SECRET_KEY",
|
"STRIPE_SECRET_KEY",
|
||||||
|
"DISCORD_WEBHOOK_URL",
|
||||||
"STRIPE_WEBHOOK_SECRET",
|
"STRIPE_WEBHOOK_SECRET",
|
||||||
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
||||||
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
||||||
@@ -120,8 +121,7 @@
|
|||||||
"PORT",
|
"PORT",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"BETTER_AUTH_TRUSTED_ORIGINS",
|
"BETTER_AUTH_TRUSTED_ORIGINS",
|
||||||
"PLUNK_API_KEY",
|
"NOVU_API_KEY"
|
||||||
"PLUNK_API_URL"
|
|
||||||
],
|
],
|
||||||
"globalPassThroughEnv": [
|
"globalPassThroughEnv": [
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
|||||||
Reference in New Issue
Block a user