feat: privacy policy + download data & delete account
This commit is contained in:
parent
95df713b06
commit
fc5c16e6dd
14 changed files with 4159 additions and 52 deletions
70
website/drizzle/0012_glamorous_white_tiger.sql
Normal file
70
website/drizzle/0012_glamorous_white_tiger.sql
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
CREATE TABLE IF NOT EXISTS "account_deletion_request" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"scheduled_deletion_at" timestamp with time zone NOT NULL,
|
||||
"reason" text,
|
||||
"is_processed" boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT "account_deletion_request_user_id_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comment" DROP CONSTRAINT "comment_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "prediction_bet" DROP CONSTRAINT "prediction_bet_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "prediction_question" DROP CONSTRAINT "prediction_question_creator_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "promo_code" DROP CONSTRAINT "promo_code_created_by_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "promo_code_redemption" DROP CONSTRAINT "promo_code_redemption_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "transaction" DROP CONSTRAINT "transaction_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "comment" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "prediction_bet" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "prediction_question" ALTER COLUMN "creator_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "promo_code_redemption" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "transaction" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "account_deletion_request" ADD CONSTRAINT "account_deletion_request_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "account_deletion_request_user_id_idx" ON "account_deletion_request" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "account_deletion_request_scheduled_deletion_idx" ON "account_deletion_request" USING btree ("scheduled_deletion_at");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "comment" ADD CONSTRAINT "comment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "prediction_bet" ADD CONSTRAINT "prediction_bet_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "prediction_question" ADD CONSTRAINT "prediction_question_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "promo_code" ADD CONSTRAINT "promo_code_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "promo_code_redemption" ADD CONSTRAINT "promo_code_redemption_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
1
website/drizzle/0013_big_champions.sql
Normal file
1
website/drizzle/0013_big_champions.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX IF NOT EXISTS "account_deletion_request_open_idx" ON "account_deletion_request" USING btree ("user_id") WHERE is_processed = false;
|
||||
1503
website/drizzle/meta/0012_snapshot.json
Normal file
1503
website/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1519
website/drizzle/meta/0013_snapshot.json
Normal file
1519
website/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -85,6 +85,20 @@
|
|||
"when": 1748528211995,
|
||||
"tag": "0011_broken_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1748537205986,
|
||||
"tag": "0012_glamorous_white_tiger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1748540176485,
|
||||
"tag": "0013_big_champions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { auth } from "$lib/auth";
|
||||
import { resolveExpiredQuestions } from "$lib/server/job";
|
||||
import { resolveExpiredQuestions, processAccountDeletions } from "$lib/server/job";
|
||||
import { svelteKitHandler } from "better-auth/svelte-kit";
|
||||
import { redis } from "$lib/server/redis";
|
||||
import { building } from '$app/environment';
|
||||
|
|
@ -38,9 +38,11 @@ async function initializeScheduler() {
|
|||
}, (lockTTL / 2) * 1000); // Renew at half the TTL
|
||||
|
||||
resolveExpiredQuestions().catch(console.error);
|
||||
processAccountDeletions().catch(console.error);
|
||||
|
||||
const schedulerInterval = setInterval(() => {
|
||||
resolveExpiredQuestions().catch(console.error);
|
||||
processAccountDeletions().catch(console.error);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Cleanup on process exit
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const userPortfolio = pgTable("user_portfolio", {
|
|||
|
||||
export const transaction = pgTable("transaction", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }),
|
||||
type: transactionTypeEnum("type").notNull(),
|
||||
quantity: decimal("quantity", { precision: 30, scale: 8 }).notNull(),
|
||||
|
|
@ -121,7 +121,7 @@ export const priceHistory = pgTable("price_history", {
|
|||
|
||||
export const comment = pgTable("comment", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
coinId: integer("coin_id").notNull().references(() => coin.id, { onDelete: "cascade" }),
|
||||
content: varchar("content", { length: 500 }).notNull(),
|
||||
likesCount: integer("likes_count").notNull().default(0),
|
||||
|
|
@ -154,12 +154,12 @@ export const promoCode = pgTable('promo_code', {
|
|||
isActive: boolean('is_active').notNull().default(true),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
createdBy: integer('created_by').references(() => user.id),
|
||||
createdBy: integer('created_by').references(() => user.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const promoCodeRedemption = pgTable('promo_code_redemption', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => user.id),
|
||||
userId: integer('user_id').references(() => user.id, { onDelete: "cascade" }),
|
||||
promoCodeId: integer('promo_code_id').notNull().references(() => promoCode.id),
|
||||
rewardAmount: decimal('reward_amount', { precision: 20, scale: 8 }).notNull(),
|
||||
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
|
@ -169,7 +169,7 @@ export const promoCodeRedemption = pgTable('promo_code_redemption', {
|
|||
|
||||
export const predictionQuestion = pgTable("prediction_question", {
|
||||
id: serial("id").primaryKey(),
|
||||
creatorId: integer("creator_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
creatorId: integer("creator_id").references(() => user.id, { onDelete: "set null" }),
|
||||
question: varchar("question", { length: 200 }).notNull(),
|
||||
status: predictionMarketEnum("status").notNull().default("ACTIVE"),
|
||||
resolutionDate: timestamp("resolution_date", { withTimezone: true }).notNull(),
|
||||
|
|
@ -192,7 +192,7 @@ export const predictionQuestion = pgTable("prediction_question", {
|
|||
|
||||
export const predictionBet = pgTable("prediction_bet", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
|
||||
userId: integer("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
questionId: integer("question_id").notNull().references(() => predictionQuestion.id, { onDelete: "cascade" }),
|
||||
side: boolean("side").notNull(), // true = YES, false = NO
|
||||
amount: decimal("amount", { precision: 20, scale: 8 }).notNull(),
|
||||
|
|
@ -208,3 +208,20 @@ export const predictionBet = pgTable("prediction_bet", {
|
|||
amountCheck: check("amount_positive", sql`amount > 0`),
|
||||
};
|
||||
});
|
||||
|
||||
export const accountDeletionRequest = pgTable("account_deletion_request", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id").notNull().references(() => user.id, { onDelete: "cascade" }).unique(),
|
||||
requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
scheduledDeletionAt: timestamp("scheduled_deletion_at", { withTimezone: true }).notNull(),
|
||||
reason: text("reason"),
|
||||
isProcessed: boolean("is_processed").notNull().default(false),
|
||||
}, (table) => {
|
||||
return {
|
||||
userIdIdx: index("account_deletion_request_user_id_idx").on(table.userId),
|
||||
scheduledDeletionIdx: index("account_deletion_request_scheduled_deletion_idx").on(table.scheduledDeletionAt),
|
||||
oneOpenRequest: index("account_deletion_request_open_idx")
|
||||
.on(table.userId)
|
||||
.where(sql`is_processed = false`),
|
||||
};
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { db } from '$lib/server/db';
|
||||
import { predictionQuestion, predictionBet, user } from '$lib/server/db/schema';
|
||||
import { predictionQuestion, predictionBet, user, accountDeletionRequest, session, account, promoCodeRedemption, userPortfolio, commentLike, comment, transaction, coin } from '$lib/server/db/schema';
|
||||
import { eq, and, lte, isNull } from 'drizzle-orm';
|
||||
import { resolveQuestion, getRugplayData } from '$lib/server/ai';
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ export async function resolveExpiredQuestions() {
|
|||
})
|
||||
.where(eq(predictionBet.id, bet.id));
|
||||
|
||||
if (won && winnings > 0) {
|
||||
if (won && winnings > 0 && bet.userId !== null) {
|
||||
const [userData] = await tx
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
|
|
@ -114,3 +114,73 @@ export async function resolveExpiredQuestions() {
|
|||
console.error('Error in resolveExpiredQuestions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function processAccountDeletions() {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const expiredRequests = await db.select()
|
||||
.from(accountDeletionRequest)
|
||||
.where(
|
||||
and(
|
||||
lte(accountDeletionRequest.scheduledDeletionAt, now),
|
||||
eq(accountDeletionRequest.isProcessed, false)
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`🗑️ Processing ${expiredRequests.length} expired account deletion requests`);
|
||||
|
||||
for (const request of expiredRequests) {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const userId = request.userId;
|
||||
|
||||
await tx.update(transaction)
|
||||
.set({ userId: null })
|
||||
.where(eq(transaction.userId, userId));
|
||||
|
||||
await tx.update(comment)
|
||||
.set({ userId: null, content: "[deleted]", isDeleted: true })
|
||||
.where(eq(comment.userId, userId));
|
||||
|
||||
await tx.update(predictionBet)
|
||||
.set({ userId: null })
|
||||
.where(eq(predictionBet.userId, userId));
|
||||
|
||||
await tx.update(predictionQuestion)
|
||||
.set({ creatorId: null })
|
||||
.where(eq(predictionQuestion.creatorId, userId));
|
||||
|
||||
await tx.update(coin)
|
||||
.set({ creatorId: null })
|
||||
.where(eq(coin.creatorId, userId));
|
||||
|
||||
await tx.delete(session).where(eq(session.userId, userId));
|
||||
await tx.delete(account).where(eq(account.userId, userId));
|
||||
await tx.delete(promoCodeRedemption).where(eq(promoCodeRedemption.userId, userId));
|
||||
await tx.delete(userPortfolio).where(eq(userPortfolio.userId, userId));
|
||||
await tx.delete(commentLike).where(eq(commentLike.userId, userId));
|
||||
|
||||
await tx.update(accountDeletionRequest)
|
||||
.set({ isProcessed: true })
|
||||
.where(eq(accountDeletionRequest.id, request.id));
|
||||
|
||||
await tx.delete(user).where(eq(user.id, userId));
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully processed account deletion for user ID: ${request.userId}`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Failed to process account deletion for user ID: ${request.userId}`, error);
|
||||
|
||||
await db.update(accountDeletionRequest)
|
||||
.set({
|
||||
isProcessed: true, // Mark as processed to avoid retries, but log the failure
|
||||
reason: request.reason ? `${request.reason} - FAILED: ${error.message}` : `FAILED: ${error.message}`
|
||||
})
|
||||
.where(eq(accountDeletionRequest.id, request.id));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing account deletions:', error);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,16 +26,17 @@ export interface PredictionQuestion {
|
|||
estimatedYesWinnings?: number;
|
||||
estimatedNoWinnings?: number;
|
||||
};
|
||||
// fuck gdpr and all that fucking shit
|
||||
recentBets?: Array<{
|
||||
id: number;
|
||||
id?: number;
|
||||
side: boolean;
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
image: string;
|
||||
user?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
image?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
|
|
|||
240
website/src/routes/api/settings/data-download/+server.ts
Normal file
240
website/src/routes/api/settings/data-download/+server.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
user,
|
||||
transaction,
|
||||
coin,
|
||||
userPortfolio,
|
||||
predictionBet,
|
||||
predictionQuestion,
|
||||
comment,
|
||||
commentLike,
|
||||
promoCodeRedemption,
|
||||
promoCode,
|
||||
session
|
||||
} from '$lib/server/db/schema';
|
||||
import { eq, and, lte } from 'drizzle-orm';
|
||||
|
||||
export async function HEAD({ request }) {
|
||||
const authSession = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!authSession?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const userId = Number(authSession.user.id);
|
||||
|
||||
try {
|
||||
// Quick check to estimate file size without generating full data
|
||||
// Get counts of major data types to estimate size
|
||||
const userExists = await db.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userExists.length) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
// Estimate file size based on typical data sizes
|
||||
// This is a rough estimate - actual size may vary
|
||||
const estimatedSize = 1024 * 50; // Base 50KB estimate
|
||||
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="rugplay-data-${userId}-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
'Content-Length': estimatedSize.toString(),
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Data export HEAD error:', err);
|
||||
throw error(500, 'Failed to check export availability');
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET({ request }) {
|
||||
const authSession = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!authSession?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const userId = Number(authSession.user.id);
|
||||
|
||||
try {
|
||||
// Get user data
|
||||
const userData = await db.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!userData.length) {
|
||||
throw error(404, 'User not found');
|
||||
}
|
||||
|
||||
// Get all user's transactions
|
||||
const transactions = await db.select({
|
||||
id: transaction.id,
|
||||
coinId: transaction.coinId,
|
||||
coinName: coin.name,
|
||||
coinSymbol: coin.symbol,
|
||||
type: transaction.type,
|
||||
quantity: transaction.quantity,
|
||||
pricePerCoin: transaction.pricePerCoin,
|
||||
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||
timestamp: transaction.timestamp
|
||||
})
|
||||
.from(transaction)
|
||||
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
||||
.where(eq(transaction.userId, userId));
|
||||
|
||||
// Get user's portfolio
|
||||
const portfolio = await db.select({
|
||||
coinId: userPortfolio.coinId,
|
||||
coinName: coin.name,
|
||||
coinSymbol: coin.symbol,
|
||||
quantity: userPortfolio.quantity,
|
||||
updatedAt: userPortfolio.updatedAt
|
||||
})
|
||||
.from(userPortfolio)
|
||||
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||
.where(eq(userPortfolio.userId, userId));
|
||||
|
||||
// Get user's prediction bets
|
||||
const predictionBets = await db.select({
|
||||
id: predictionBet.id,
|
||||
questionId: predictionBet.questionId,
|
||||
question: predictionQuestion.question,
|
||||
side: predictionBet.side,
|
||||
amount: predictionBet.amount,
|
||||
actualWinnings: predictionBet.actualWinnings,
|
||||
createdAt: predictionBet.createdAt,
|
||||
settledAt: predictionBet.settledAt
|
||||
})
|
||||
.from(predictionBet)
|
||||
.leftJoin(predictionQuestion, eq(predictionBet.questionId, predictionQuestion.id))
|
||||
.where(eq(predictionBet.userId, userId));
|
||||
|
||||
// Get user's comments
|
||||
const comments = await db.select({
|
||||
id: comment.id,
|
||||
coinId: comment.coinId,
|
||||
coinName: coin.name,
|
||||
coinSymbol: coin.symbol,
|
||||
content: comment.content,
|
||||
likesCount: comment.likesCount,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
isDeleted: comment.isDeleted
|
||||
})
|
||||
.from(comment)
|
||||
.leftJoin(coin, eq(comment.coinId, coin.id))
|
||||
.where(eq(comment.userId, userId));
|
||||
|
||||
// Get user's comment likes
|
||||
const commentLikes = await db.select({
|
||||
commentId: commentLike.commentId,
|
||||
createdAt: commentLike.createdAt
|
||||
})
|
||||
.from(commentLike)
|
||||
.where(eq(commentLike.userId, userId));
|
||||
|
||||
// Get user's promo code redemptions
|
||||
const promoRedemptions = await db.select({
|
||||
id: promoCodeRedemption.id,
|
||||
promoCodeId: promoCodeRedemption.promoCodeId,
|
||||
promoCode: promoCode.code,
|
||||
rewardAmount: promoCodeRedemption.rewardAmount,
|
||||
redeemedAt: promoCodeRedemption.redeemedAt
|
||||
})
|
||||
.from(promoCodeRedemption)
|
||||
.leftJoin(promoCode, eq(promoCodeRedemption.promoCodeId, promoCode.id))
|
||||
.where(eq(promoCodeRedemption.userId, userId));
|
||||
|
||||
// Get user's sessions (limited to active ones for privacy)
|
||||
const sessions = await db.select({
|
||||
id: session.id,
|
||||
expiresAt: session.expiresAt,
|
||||
createdAt: session.createdAt,
|
||||
ipAddress: session.ipAddress,
|
||||
userAgent: session.userAgent
|
||||
})
|
||||
.from(session)
|
||||
.where(and(
|
||||
eq(session.userId, userId),
|
||||
lte(session.expiresAt, new Date())
|
||||
))
|
||||
|
||||
// Get coins created by user
|
||||
const createdCoins = await db.select({
|
||||
id: coin.id,
|
||||
name: coin.name,
|
||||
symbol: coin.symbol,
|
||||
icon: coin.icon,
|
||||
initialSupply: coin.initialSupply,
|
||||
circulatingSupply: coin.circulatingSupply,
|
||||
marketCap: coin.marketCap,
|
||||
price: coin.currentPrice,
|
||||
change24h: coin.change24h,
|
||||
poolCoinAmount: coin.poolCoinAmount,
|
||||
poolBaseCurrencyAmount: coin.poolBaseCurrencyAmount,
|
||||
createdAt: coin.createdAt,
|
||||
updatedAt: coin.updatedAt,
|
||||
isListed: coin.isListed
|
||||
})
|
||||
.from(coin)
|
||||
.where(eq(coin.creatorId, userId));
|
||||
|
||||
// Get questions created by user
|
||||
const createdQuestions = await db.select()
|
||||
.from(predictionQuestion)
|
||||
.where(eq(predictionQuestion.creatorId, userId));
|
||||
|
||||
// Compile all data
|
||||
const exportData = {
|
||||
exportInfo: {
|
||||
userId: userId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
dataType: 'complete_user_data'
|
||||
},
|
||||
user: userData[0],
|
||||
transactions: transactions,
|
||||
portfolio: portfolio,
|
||||
predictionBets: predictionBets,
|
||||
comments: comments,
|
||||
commentLikes: commentLikes,
|
||||
promoCodeRedemptions: promoRedemptions,
|
||||
sessions: sessions,
|
||||
createdCoins: createdCoins,
|
||||
createdQuestions: createdQuestions
|
||||
}; // Serialize the data
|
||||
const jsonData = JSON.stringify(exportData, null, 2);
|
||||
const dataSize = new TextEncoder().encode(jsonData).length;
|
||||
|
||||
// Return as downloadable JSON with proper headers for streaming download
|
||||
return new Response(jsonData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="rugplay-data-${userId}-${new Date().toISOString().split('T')[0]}.json"`,
|
||||
'Content-Length': dataSize.toString(),
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Data export error:', err);
|
||||
throw error(500, 'Failed to export user data');
|
||||
}
|
||||
}
|
||||
58
website/src/routes/api/settings/delete-account/+server.ts
Normal file
58
website/src/routes/api/settings/delete-account/+server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user, accountDeletionRequest } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const authSession = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!authSession?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
const userId = Number(authSession.user.id);
|
||||
const body = await request.json();
|
||||
const { confirmationText } = body;
|
||||
|
||||
if (confirmationText !== 'DELETE MY ACCOUNT') {
|
||||
throw error(400, 'Invalid confirmation text');
|
||||
}
|
||||
|
||||
const scheduledDeletionAt = new Date();
|
||||
scheduledDeletionAt.setDate(scheduledDeletionAt.getDate() + 14);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existingRequest = await tx.select()
|
||||
.from(accountDeletionRequest)
|
||||
.where(eq(accountDeletionRequest.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existingRequest.length > 0) {
|
||||
throw new Error('Account deletion already requested');
|
||||
}
|
||||
|
||||
await tx.insert(accountDeletionRequest).values({
|
||||
userId,
|
||||
scheduledDeletionAt,
|
||||
reason: 'User requested account deletion'
|
||||
});
|
||||
|
||||
await tx.update(user)
|
||||
.set({
|
||||
isBanned: true,
|
||||
banReason: 'Account deletion requested - scheduled for ' + scheduledDeletionAt.toISOString(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
});
|
||||
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: `Account deletion has been scheduled for ${scheduledDeletionAt.toLocaleDateString()}. Your account has been temporarily suspended. You can cancel this request by contacting support before the scheduled date.`,
|
||||
scheduledDeletionAt: scheduledDeletionAt.toISOString()
|
||||
});
|
||||
}
|
||||
|
|
@ -570,28 +570,36 @@
|
|||
<Card.Content class="pb-6">
|
||||
<div class="space-y-4">
|
||||
{#each question.recentBets as bet}
|
||||
{#if bet.user}
|
||||
<div class="flex items-center justify-between rounded-xl border p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-3 text-left"
|
||||
onclick={() => goto(`/user/${bet.user.username}`)}
|
||||
onclick={() => goto(`/user/${bet.user?.username}`)}
|
||||
>
|
||||
<Avatar.Root class="h-10 w-10">
|
||||
<Avatar.Image src={getPublicUrl(bet.user.image)} alt={bet.user.name} />
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(bet.user?.image || null)}
|
||||
alt={bet.user?.name || bet.user?.username}
|
||||
/>
|
||||
<Avatar.Fallback class="text-sm"
|
||||
>{bet.user.name.charAt(0)}</Avatar.Fallback
|
||||
>{(bet.user?.name || bet.user?.username || 'U').charAt(
|
||||
0
|
||||
)}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
||||
<div>
|
||||
<div class="font-semibold hover:underline">{bet.user.name}</div>
|
||||
<div class="text-muted-foreground text-sm">@{bet.user.username}</div>
|
||||
<div class="font-semibold hover:underline">{bet.user?.name || "Deleted User"}</div>
|
||||
<div class="text-muted-foreground text-sm">@{bet.user?.username || "deleted_user"}</div>
|
||||
</div>
|
||||
</button>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80">
|
||||
<UserProfilePreview userId={bet.user.id} />
|
||||
{#if bet.user?.id}
|
||||
<UserProfilePreview userId={bet.user?.id} />
|
||||
{/if}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
<Badge variant="destructive" class={bet.side ? 'bg-success/80!' : ''}>
|
||||
|
|
@ -605,6 +613,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
|
|
|||
413
website/src/routes/legal/privacy/+page.svelte
Normal file
413
website/src/routes/legal/privacy/+page.svelte
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
<script lang="ts">
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import ShieldCheck from 'lucide-svelte/icons/shield-check';
|
||||
import AlertTriangle from 'lucide-svelte/icons/alert-triangle';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const LAST_UPDATED = 'May 29, 2025';
|
||||
const CONTACT_EMAIL = 'privacy@outpoot.com';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy - Rugplay</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Learn how we protect your privacy on Rugplay and what data we retain after account deletion."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="privacy, policy, data protection, crypto, trading, GDPR, rugplay"
|
||||
/>
|
||||
<meta property="og:title" content="Privacy Policy - Rugplay" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Learn how we protect your privacy on Rugplay and what data we retain after account deletion."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={page.url.href} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-4xl py-10">
|
||||
<Card.Root class="p-6">
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<ShieldCheck class="text-primary h-10 w-10" />
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold">Privacy Policy</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Last updated: {LAST_UPDATED}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert.Root class="mb-6">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<Alert.Title>Important Notice About Account Deletion</Alert.Title>
|
||||
<Alert.Description>
|
||||
When you delete your account, all personal information is permanently removed, but some
|
||||
anonymized data may be retained for platform integrity. This is explained in detail below.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">1. Our Privacy Commitment</h2>
|
||||
<p class="mb-4">
|
||||
We are committed to protecting your privacy while providing a secure and functional crypto
|
||||
trading simulation platform. This policy explains exactly what data we collect, how we use
|
||||
it, and what happens when you delete your account.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Platform Note:</strong> Rugplay is a simulated trading environment using virtual currency
|
||||
("*BUSS" or "$") with no real monetary value.
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">2. Information We Collect</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">2.1 Account Information</h3>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Email address</li>
|
||||
<li>Username</li>
|
||||
<li>Profile information (name, bio, profile picture)</li>
|
||||
<li>Account preferences and settings</li>
|
||||
<li>Authentication tokens and session data</li>
|
||||
<li>
|
||||
IP addresses and browser/user agent information (for security and session
|
||||
management)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">2.2 Trading and Financial Data (Simulated)</h3>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Transaction history (buy/sell orders, amounts, prices, timestamps)</li>
|
||||
<li>Portfolio holdings and balances</li>
|
||||
<li>Trading patterns and preferences</li>
|
||||
<li>Prediction market bets and outcomes</li>
|
||||
<li>Reward claims and promotional code usage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">2.3 Platform Activity</h3>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Comments and posts on coin pages</li>
|
||||
<li>Likes and interactions with content</li>
|
||||
<li>Login activity and streaks</li>
|
||||
<!-- IP addresses and browser information already covered in 2.1 -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">3. How We Use Your Data</h2>
|
||||
<p class="mb-4">We use your data for:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Providing trading platform functionality</li>
|
||||
<li>Maintaining accurate portfolio and transaction records</li>
|
||||
<li>Calculating market prices and liquidity</li>
|
||||
<li>Fraud prevention and security monitoring</li>
|
||||
<li>Platform analytics and improvements</li>
|
||||
<li>Resolving disputes and maintaining system integrity</li>
|
||||
<li>
|
||||
Operating and resolving prediction markets, which may involve automated decision-making
|
||||
as detailed below.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="mb-2 mt-4 text-lg font-medium">
|
||||
3.1 Automated Decision-Making (Prediction Markets)
|
||||
</h3>
|
||||
<p class="mb-2">
|
||||
Our prediction markets are entirely AI-automated systems that determine outcomes of
|
||||
prediction questions without human intervention. The AI processes publicly available
|
||||
information and platform data to settle expired prediction questions automatically.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>No Guarantee of Accuracy:</strong> AI decisions are not guaranteed to be correct or
|
||||
accurate. The automated system may make errors in interpreting data or determining outcomes.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>Significance and Envisaged Consequences:</strong> These automated decisions directly
|
||||
determine whether your prediction bets are won or lost and the corresponding virtual payouts.
|
||||
All outcomes are final once processed.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Limitation of Liability:</strong> We are not responsible for any losses, incorrect
|
||||
outcomes, or disputes arising from automated AI decisions in prediction markets. By participating,
|
||||
you acknowledge and accept the risks of AI-automated resolution systems.
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="text-destructive mb-4 text-2xl font-semibold">
|
||||
4. Account Deletion and Data Retention
|
||||
</h2>
|
||||
|
||||
<Alert.Root class="mb-4">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<Alert.Title>14-Day Deletion Process</Alert.Title>
|
||||
<Alert.Description>
|
||||
Account deletion is scheduled 14 days after your request. During this period, your
|
||||
account is suspended but you can contact support to cancel the deletion.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium" style="color: oklch(var(--success))">
|
||||
4.1 What Gets Permanently Deleted
|
||||
</h3>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Your personal information (name, email, username, bio, profile picture)</li>
|
||||
<li>
|
||||
Authentication sessions (including IP address and user agent history) and login
|
||||
tokens
|
||||
</li>
|
||||
<li>OAuth account connections</li>
|
||||
<li>Account preferences and settings</li>
|
||||
<li>Portfolio holdings and balances (*BUSS ("$") and simulated coins)</li>
|
||||
<li>Records of your likes on comments</li>
|
||||
<li>Promo code redemption records linked to your account</li>
|
||||
<li>Your user account record itself</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border-2 p-4"
|
||||
style="border-color: oklch(0.828 0.189 84.429); background-color: oklch(0.828 0.189 84.429 / 0.1);"
|
||||
>
|
||||
<h3 class="mb-2 text-lg font-medium" style="color: oklch(0.828 0.189 84.429)">
|
||||
4.2 What May Remain (Fully Anonymized)
|
||||
</h3>
|
||||
<p class="mb-3" style="color: oklch(0.828 0.189 84.429 / 0.8)">
|
||||
<strong
|
||||
>The following data may remain but is completely disconnected from your identity
|
||||
(your User ID is removed):</strong
|
||||
>
|
||||
</p>
|
||||
<ul class="ml-6 list-disc space-y-2" style="color: oklch(0.828 0.189 84.429 / 0.8)">
|
||||
<li>
|
||||
<strong>Transaction records:</strong> Trading history (buys/sells, amounts, prices, timestamps)
|
||||
with your User ID removed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Comment content:</strong> Your comments will have their User ID removed, the
|
||||
content replaced with "[deleted]", and marked as deleted. Timestamps and like counts
|
||||
(aggregated on the comment itself) are preserved.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Prediction bets:</strong> Records of bets placed, with your User ID removed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Created coins:</strong> Simulated coins you created will remain, but your User
|
||||
ID as creator will be removed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Created prediction questions:</strong> Prediction questions you created will
|
||||
remain, but your User ID as creator will be removed.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">4.3 Why Some Data Remains Anonymized</h3>
|
||||
<p class="mb-3">Anonymized data serves these legitimate purposes:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>
|
||||
<strong>Market Integrity:</strong> Historical transaction data is needed to maintain
|
||||
accurate price charts and trading history
|
||||
</li>
|
||||
<li>
|
||||
<strong>Platform Function:</strong> Coins and prediction markets must remain functional
|
||||
even after creators delete their accounts
|
||||
</li>
|
||||
<li>
|
||||
<strong>Community Content:</strong> Comments remain available to preserve discussion
|
||||
threads, but with anonymous authorship
|
||||
</li>
|
||||
<li>
|
||||
<strong>System Balance:</strong> Transaction records ensure the platform's virtual economy
|
||||
remains mathematically consistent
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted-foreground mt-3 text-sm">
|
||||
<strong>Important:</strong> This anonymized data cannot be linked back to you in any way
|
||||
as all personal identifiers are permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">4.4 Deletion Timeline</h3>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li><strong>Immediate:</strong> Account suspended and login disabled</li>
|
||||
<li>
|
||||
<strong>14 days later:</strong> Complete deletion process executed automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cancellation:</strong> Contact support within 14 days to cancel deletion
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">5. Data Security</h2>
|
||||
<p class="mb-4">We implement industry-standard security measures including:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Encryption of sensitive data in transit and at rest</li>
|
||||
<li>Regular security audits and monitoring</li>
|
||||
<li>Access controls and authentication requirements</li>
|
||||
<li>Secure database configurations and backups</li>
|
||||
</ul>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">6. Your Data Protection Rights</h2>
|
||||
<p class="mb-4">
|
||||
Depending on your location, you may have certain rights regarding your personal data,
|
||||
including:
|
||||
</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li><strong>Access:</strong> The right to access the personal data we hold about you.</li>
|
||||
<li>
|
||||
<strong>Rectification:</strong> The right to correct inaccurate or incomplete data.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Erasure (Deletion):</strong> The right to request deletion of your personal data
|
||||
(subject to our retention policy and legal obligations, as described in Section 4).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Portability:</strong> The right to export your data in a structured, commonly used,
|
||||
and machine-readable format.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Restrict Processing:</strong> The right to request restriction of processing your
|
||||
personal data under certain conditions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Object to Processing:</strong> The right to object to processing of your personal
|
||||
data under certain conditions, including for direct marketing purposes (which we do not currently
|
||||
engage in).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rights related to automated decision-making:</strong> The right not to be subject
|
||||
to a decision based solely on automated processing, including profiling, which produces legal
|
||||
effects concerning you or similarly significantly affects you, except under certain conditions.
|
||||
As described in Section 3.1, we use automated decision-making for prediction market resolution,
|
||||
and we provide information and recourse options.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3">
|
||||
To exercise these rights, please contact us at <a
|
||||
href="mailto:{CONTACT_EMAIL}"
|
||||
class="text-primary underline">{CONTACT_EMAIL}</a
|
||||
>.
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">7. Consent and Legal Basis</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">7.1 Explicit Consent Required</h3>
|
||||
<p class="mb-3">By creating an account, you explicitly consent to:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>Our data collection and processing practices</li>
|
||||
<li>The anonymization and retention policy described in Section 4</li>
|
||||
<li>Processing of your trading data for platform functionality</li>
|
||||
<li>14-day deletion process with suspension during the waiting period</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-medium">7.2 Withdrawal of Consent</h3>
|
||||
<p>You can withdraw consent at any time by deleting your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">8. Data Sharing</h2>
|
||||
<p class="mb-4">We do not sell or share your personal data with third parties, except:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>When required by law or legal process</li>
|
||||
<li>To prevent fraud or protect platform security</li>
|
||||
<li>
|
||||
With service providers who assist in platform operations (under strict data processing
|
||||
agreements)
|
||||
</li>
|
||||
</ul>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">9. International Data Transfers</h2>
|
||||
<p class="mb-4">
|
||||
Your data may be processed outside the EU. We ensure adequate protection through standard
|
||||
contractual clauses and appropriate safeguards as required by GDPR.
|
||||
</p>
|
||||
</Card.Content>
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">10. Cookies and Tracking Technologies</h2>
|
||||
<p class="mb-4">
|
||||
We use cookies solely for authentication and session management purposes. These essential
|
||||
cookies are necessary to maintain your login state and provide secure access to your
|
||||
account.
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">11. Contact and Complaints</h2>
|
||||
<p class="mb-4">For privacy-related questions or to exercise your rights:</p>
|
||||
<ul class="ml-6 list-disc space-y-2">
|
||||
<li>
|
||||
Email: <a href="mailto:{CONTACT_EMAIL}" class="text-primary underline"
|
||||
>{CONTACT_EMAIL}</a
|
||||
>
|
||||
</li>
|
||||
<li>To cancel account deletion: Contact us immediately at the above email</li>
|
||||
<li>You have the right to lodge a complaint with your local data protection authority</li>
|
||||
</ul>
|
||||
</Card.Content>
|
||||
|
||||
<Card.Content>
|
||||
<h2 class="mb-4 text-2xl font-semibold">12. Policy Updates</h2>
|
||||
<p>
|
||||
We will notify you of material changes to this policy via email and platform
|
||||
notifications. Continued use after changes constitutes acceptance of the updated policy.
|
||||
</p>
|
||||
</Card.Content>
|
||||
|
||||
<div class="rounded-lg p-4 text-sm" style="background-color: oklch(var(--primary) / 0.1);">
|
||||
<p class="mb-2"><strong>Last Updated:</strong> {LAST_UPDATED}</p>
|
||||
<p class="mb-2">
|
||||
<strong>Contact:</strong>
|
||||
<a href="mailto:{CONTACT_EMAIL}" class="text-primary underline">{CONTACT_EMAIL}</a>
|
||||
</p>
|
||||
<p><strong>Platform:</strong> Rugplay - virtual cryptocurrency trading simulation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-8" />
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<Button variant="outline" size="lg" onclick={() => goto('/legal/terms')}>
|
||||
Terms of Service →
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" onclick={() => goto('/settings')}>
|
||||
Export My Data
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
|
@ -9,11 +9,12 @@
|
|||
import * as Card from '$lib/components/ui/card';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { CheckIcon, Volume2Icon, VolumeXIcon } from 'lucide-svelte';
|
||||
import { CheckIcon, Volume2Icon, VolumeXIcon, DownloadIcon, Trash2Icon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { MAX_FILE_SIZE } from '$lib/data/constants';
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
let name = $state($USER_DATA?.name || '');
|
||||
let bio = $state($USER_DATA?.bio ?? '');
|
||||
|
|
@ -37,10 +38,14 @@
|
|||
let loading = $state(false);
|
||||
let usernameAvailable: boolean | null = $state(null);
|
||||
let checkingUsername = $state(false);
|
||||
|
||||
let masterVolume = $state(($USER_DATA?.volumeMaster || 0) * 100);
|
||||
let isMuted = $state($USER_DATA?.volumeMuted || false);
|
||||
|
||||
let deleteDialogOpen = $state(false);
|
||||
let deleteConfirmationText = $state('');
|
||||
let isDeleting = $state(false);
|
||||
let isDownloading = $state(false);
|
||||
|
||||
function beforeUnloadHandler(e: BeforeUnloadEvent) {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
|
|
@ -155,12 +160,103 @@
|
|||
volumeSettings.setMaster(normalizedValue);
|
||||
saveVolumeToServer({ master: normalizedValue, muted: isMuted });
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
isMuted = !isMuted;
|
||||
volumeSettings.setMuted(isMuted);
|
||||
saveVolumeToServer({ master: masterVolume / 100, muted: isMuted });
|
||||
}
|
||||
|
||||
async function downloadUserData() {
|
||||
isDownloading = true;
|
||||
try {
|
||||
const headResponse = await fetch('/api/settings/data-download', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
|
||||
if (!headResponse.ok) {
|
||||
throw new Error('Download service unavailable');
|
||||
}
|
||||
|
||||
const contentLength = headResponse.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
const sizeInMB = parseInt(contentLength) / (1024 * 1024);
|
||||
if (sizeInMB > 50) {
|
||||
const proceed = confirm(
|
||||
`Your data export is ${sizeInMB.toFixed(1)}MB. This may take a while to download. Continue?`
|
||||
);
|
||||
if (!proceed) {
|
||||
isDownloading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadUrl = '/api/settings/data-download';
|
||||
|
||||
const downloadWindow = window.open(downloadUrl, '_blank');
|
||||
|
||||
if (!downloadWindow || downloadWindow.closed) {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.style.display = 'none';
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
downloadWindow.close();
|
||||
} catch (e) {}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
toast.success('Your data download has started');
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
toast.error('Failed to start data download: ' + (error as Error).message);
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (deleteConfirmationText !== 'DELETE MY ACCOUNT') {
|
||||
toast.error('Please type "DELETE MY ACCOUNT" to confirm');
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting = true;
|
||||
try {
|
||||
const response = await fetch('/api/settings/delete-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmationText: deleteConfirmationText
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json();
|
||||
throw new Error(result.message || 'Failed to delete account');
|
||||
}
|
||||
|
||||
toast.success('Account deleted successfully. You will be logged out shortly.');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.error('Delete account error:', error);
|
||||
toast.error('Failed to delete account: ' + error.message);
|
||||
} finally {
|
||||
isDeleting = false;
|
||||
deleteDialogOpen = false;
|
||||
deleteConfirmationText = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-2xl p-6">
|
||||
|
|
@ -289,5 +385,99 @@
|
|||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Data & Privacy</Card.Title>
|
||||
<Card.Description>Manage your personal data and account</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between rounded-lg border p-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-medium">Download Your Data</h4>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Export a complete copy of your account data including transactions, bets, and
|
||||
profile information.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={downloadUserData}
|
||||
disabled={isDownloading}
|
||||
class="ml-4"
|
||||
>
|
||||
<DownloadIcon class="h-4 w-4" />
|
||||
{isDownloading ? 'Downloading...' : 'Download Data'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-destructive/20 bg-destructive/5 flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-destructive text-sm font-medium">Delete Account</h4>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Permanently delete your account. This will anonymize your data while preserving
|
||||
transaction records for compliance.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => (deleteDialogOpen = true)}
|
||||
class="ml-4"
|
||||
>
|
||||
<Trash2Icon class="h-4 w-4" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={deleteDialogOpen}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-destructive">Delete Account</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
This action cannot be undone. Your account will be permanently deleted and your data will be
|
||||
anonymized.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-destructive/10 rounded-lg p-4">
|
||||
<h4 class="mb-2 text-sm font-medium">What happens when you delete your account:</h4>
|
||||
<ul class="text-muted-foreground space-y-1 text-xs">
|
||||
<li>• Your profile information will be permanently removed</li>
|
||||
<li>• You will be logged out from all devices</li>
|
||||
<li>• Your comments will be anonymized</li>
|
||||
<li>• Transaction history will be preserved for compliance (anonymized)</li>
|
||||
<li>• You will not be able to recover this account</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="delete-confirmation">Type "DELETE MY ACCOUNT" to confirm:</Label>
|
||||
<Input
|
||||
id="delete-confirmation"
|
||||
bind:value={deleteConfirmationText}
|
||||
placeholder="DELETE MY ACCOUNT"
|
||||
class="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (deleteDialogOpen = false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onclick={deleteAccount}
|
||||
disabled={isDeleting || deleteConfirmationText !== 'DELETE MY ACCOUNT'}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Account'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
|
|||
Reference in a new issue