Skip to content
// socket.io
export type Subscription = "heatmap";
export interface ServerToClientEvents {
canvas: (pixels: string[]) => void;
canvas: (
start: [x: number, y: number],
end: [x: number, y: number],
pixels: string[]
) => void;
clearCanvasChunks: () => void;
user: (user: AuthSession) => void;
standing: (standing: IAccountStanding) => void;
config: (config: ClientConfig) => void;
pixel: (pixel: Pixel) => void;
online: (count: { count: number }) => void;
availablePixels: (count: number) => void;
pixelLastPlaced: (time: number) => void;
undo: (
data: { available: false } | { available: true; expireAt: number }
) => void;
square: (
start: [x: number, y: number],
end: [x: number, y: number],
color: number
) => void;
alert: (alert: IAlert) => void;
alert_dismiss: (id: string) => void;
recaptcha: (site_key: string) => void;
recaptcha_challenge: (ack: (token: string) => void) => void;
/* --- subscribe events --- */
/**
* Emitted to room `sub:heatmap`
* @param heatmap
* @returns
*/
heatmap: (heatmap: string) => void;
}
export interface ClientToServerEvents {
place: (
pixel: Pixel,
bypassCooldown: boolean,
ack: (
_: PacketAck<
Pixel,
"no_user" | "invalid_pixel" | "pixel_cooldown" | "palette_color_invalid"
| "canvas_frozen"
| "no_user"
| "invalid_pixel"
| "pixel_cooldown"
| "palette_color_invalid"
| "you_already_placed_that"
| "banned"
| "pixel_already_pending"
>
) => void
) => void;
undo: (
ack: (
_: PacketAck<
{},
"canvas_frozen" | "no_user" | "unavailable" | "pixel_covered"
>
) => void
) => void;
}
// app context
export interface IAppContext {
config: ClientConfig;
user?: AuthSession;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursorPosition?: IPosition;
setCursorPosition: (v?: IPosition) => void;
pixels: { available: number };
}
export interface IPalleteContext {
color?: number;
subscribe: (topic: Subscription) => void;
unsubscribe: (topic: Subscription) => void;
}
export interface ICanvasPosition {
export interface IPosition {
x: number;
y: number;
zoom: number;
}
export interface IPosition {
x: number;
y: number;
export type IAccountStanding =
| {
banned: false;
}
| {
banned: true;
/**
* ISO timestamp
*/
until: string;
reason?: string;
};
/**
* Typescript magic
*
* key => name of the event
* value => what metadata the message will include
*/
export interface IAlertKeyedMessages {
banned: {
/**
* ISO date
*/
until: string;
};
unbanned: {};
}
export type IAlert<Is extends "toast" | "modal" = "toast" | "modal"> = {
is: Is;
action: "system" | "moderation";
id?: string;
} & (
| {
is: "toast";
severity: "info" | "success" | "warning" | "error" | "default";
autoDismiss: boolean;
}
| {
is: "modal";
dismissable: boolean;
}
) &
(IAlertKeyed | { title: string; body?: string });
/**
* Typescript magic
*
* #metadata depends on message_key and is mapped via IAlertKeyedMessages
*/
type IAlertKeyed = keyof IAlertKeyedMessages extends infer MessageKey
? MessageKey extends keyof IAlertKeyedMessages
? {
message_key: MessageKey;
metadata: IAlertKeyedMessages[MessageKey];
}
: never
: never;
// other
export type Pixel = {
x: number;
y: number;
/**
* Palette color ID or -1 for nothing
*/
color: number;
};
......@@ -65,20 +156,51 @@ export type PalleteColor = {
export type CanvasConfig = {
size: [number, number];
frozen: boolean;
zoom: number;
pixel: {
maxStack: number;
cooldown: number;
multiplier: number;
};
undo: {
/**
* time in ms to allow undos
*/
grace_period: number;
};
};
export type ClientConfig = {
/**
* Monolith git hash, if it doesn't match, client will reload
*/
version: string;
pallete: {
colors: PalleteColor[];
/**
* ms for cooldown
* @see CanvasLib#getPixelCooldown
*/
pixel_cooldown: number;
};
canvas: CanvasConfig;
chat: {
enabled: boolean;
/**
* @example aftermath.gg
*/
matrix_homeserver: string;
/**
* @example https://chat.fediverse.events
*/
element_host: string;
/**
* URI encoded alias
* @example %23canvas-general:aftermath.gg
*/
general_alias: string;
};
};
/**
......@@ -97,13 +219,20 @@ export type AuthSession = {
software: {
name: string;
version: string;
logo_uri?: string;
repository?: string;
homepage?: string;
};
instance: {
hostname: string;
logo_uri?: string;
banner_uri?: string;
name?: string;
};
};
user: {
username: string;
profile: string;
display_name?: string;
picture_url?: string;
};
};
......@@ -9,6 +9,7 @@ import {
handleCalculateZoomPositions,
} from "./lib/zoom.utils";
import { Panning } from "./lib/panning.utils";
import { Debug } from "../debug";
interface TransformState {
/**
......@@ -88,8 +89,15 @@ interface ISetup {
// TODO: move these event interfaces out
export interface ClickEvent {
button: "LCLICK" | "MCLICK" | "RCLICK";
clientX: number;
clientY: number;
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
}
export interface HoverEvent {
......@@ -104,6 +112,7 @@ export interface ViewportMoveEvent {
}
interface PanZoomEvents {
longPress: (x: number, y: number) => void;
doubleTap: (e: TouchEvent) => void;
click: (e: ClickEvent) => void;
hover: (e: HoverEvent) => void;
......@@ -156,6 +165,8 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.flags = {
useZoom: false,
};
this.detectFlags();
}
initialize(
......@@ -167,7 +178,6 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.$zoom = $zoom;
this.$move = $move;
this.detectFlags();
this.registerMouseEvents();
this.registerTouchEvents();
......@@ -188,6 +198,51 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.emit("initialize");
}
/**
* Get scale that would fit the zoom element in the viewport
* @returns
*/
getZoomToFit() {
// https://github.com/BetterTyped/react-zoom-pan-pinch/blob/8dacc2746ca84db22f30275e0745c04aefde5aea/src/core/handlers/handlers.utils.ts#L141
// const wrapperRect = this.$wrapper.getBoundingClientRect();
const nodeRect = this.$zoom.getBoundingClientRect();
// const nodeOffset = this._getOffset();
// const nodeLeft = nodeOffset.x;
// const nodeTop = nodeOffset.y;
const nodeWidth = nodeRect.width / this.transform.scale;
const nodeHeight = nodeRect.height / this.transform.scale;
const scaleX = this.$wrapper.offsetWidth / nodeWidth;
const scaleY = this.$wrapper.offsetHeight / nodeHeight;
const newScale = Math.min(scaleX, scaleY);
return newScale;
// the following is from the zoomToElement from react-zoom-pan-pinch
// const offsetX = (wrapperRect.width - nodeWidth * newScale) / 2;
// const offsetY = (wrapperRect.height - nodeHeight * newScale) / 2;
// const newPositionX = (wrapperRect.left - nodeLeft) * newScale + offsetX;
// const newPositionY = (wrapperRect.top - nodeTop) * newScale + offsetY;
}
// https://github.com/BetterTyped/react-zoom-pan-pinch/blob/8dacc2746ca84db22f30275e0745c04aefde5aea/src/core/handlers/handlers.utils.ts#L122
_getOffset() {
const wrapperOffset = this.$wrapper.getBoundingClientRect();
const contentOffset = this.$zoom.getBoundingClientRect();
const xOff = wrapperOffset.x * this.transform.scale;
const yOff = wrapperOffset.y * this.transform.scale;
return {
x: (contentOffset.x + xOff) / this.transform.scale,
y: (contentOffset.y + yOff) / this.transform.scale,
};
}
/**
* Sets transform data
*
......@@ -303,6 +358,8 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
* @param e
*/
private _touch_touchstart = (event: TouchEvent) => {
event.preventDefault();
const isDoubleTap =
this.touch.lastTouch && +new Date() - this.touch.lastTouch < 200;
......@@ -332,7 +389,7 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
* @param e
*/
private _touch_touchmove = (event: TouchEvent) => {
if (this.panning.enabled && event.touches.length === 1) {
if (this.panning.active && event.touches.length === 1) {
event.preventDefault();
event.stopPropagation();
......@@ -351,8 +408,30 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
* @param e
*/
private _touch_touchend = (event: TouchEvent) => {
if (this.panning.enabled) {
this.panning.enabled = false;
if (this.touch.lastTouch && this.panning.active) {
const touch = event.changedTouches[0];
const dx = Math.abs(this.panning.x - touch.clientX);
const dy = Math.abs(this.panning.y - touch.clientY);
if (Date.now() - this.touch.lastTouch < 500 && dx < 5 && dy < 5) {
this.emit("click", {
clientX: touch.clientX,
clientY: touch.clientY,
button: "LCLICK",
alt: false,
shift: false,
ctrl: false,
meta: false,
});
}
if (Date.now() - this.touch.lastTouch > 500 && dx < 25 && dy < 25) {
this.emit("longPress", this.panning.x, this.panning.y);
}
}
if (this.panning.active) {
this.panning.active = false;
const touch = event.changedTouches[0];
......@@ -370,7 +449,7 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.touch.pinchStartDistance = distance;
this.touch.lastDistance = distance;
this.touch.pinchStartScale = this.transform.scale;
this.panning.enabled = false;
this.panning.active = false;
}
onPinch(event: TouchEvent) {
......@@ -410,7 +489,9 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
if (newScale === scale) return;
// const { x, y } = handleCalculateZoomPositions(
// this returns diff of pixels due to css zoom being used
//
// let { x, y } = handleCalculateZoomPositions(
// this,
// midPoint.x,
// midPoint.y,
......@@ -420,32 +501,39 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.touch.pinchMidpoint = midPoint;
this.touch.lastDistance = currentDistance;
this.debug(midPoint.x, midPoint.y, "midpoint");
if (Debug.flags.enabled("PANZOOM_PINCH_DEBUG_MESSAGES")) {
Debug.debug("point", midPoint.x, midPoint.y, "midpoint");
Debug.debug("text", {
scale: [scale, newScale],
x: midPoint.x,
y: midPoint.y,
tx: this.transform.x,
ty: this.transform.y,
xx: midPoint.x * newScale - midPoint.x * scale,
yy: midPoint.y * newScale - midPoint.y * scale,
});
}
// TODO: this might be css zoom specific, I have no way to test this
this.transform.x = midPoint.x / newScale - midPoint.x / scale;
this.transform.y = midPoint.y / newScale - midPoint.x / scale;
if (Debug.flags.enabled("PANZOOM_PINCH_TRANSFORM_1")) {
this.transform.x = midPoint.x / newScale - midPoint.x / scale;
this.transform.y = midPoint.y / newScale - midPoint.y / scale;
}
if (Debug.flags.enabled("PANZOOM_PINCH_TRANSFORM_2")) {
this.transform.x = (midPoint.x - this.transform.x) / (newScale - scale);
this.transform.y = (midPoint.y - this.transform.y) / (newScale - scale);
}
this.transform.scale = newScale;
this.update();
}
debug(x: number, y: number, id?: string) {
// if (document.getElementById("debug-" + id)) {
// document.getElementById("debug-" + id)!.style.top = y + "px";
// document.getElementById("debug-" + id)!.style.left = x + "px";
// return;
// }
// let el = document.createElement("div");
// if (id) el.id = "debug-" + id;
// el.classList.add("debug-point");
// el.style.setProperty("top", y + "px");
// el.style.setProperty("left", x + "px");
// document.body.appendChild(el);
}
registerMouseEvents() {
console.debug("[PanZoom] Registering mouse events to $wrapper & document");
this.$wrapper.addEventListener("contextmenu", (e) => {
e.preventDefault();
});
// zoom
this.$wrapper.addEventListener("wheel", this._mouse_wheel, {
passive: true,
......@@ -534,7 +622,9 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
this.mouse.mouseDown = Date.now();
this.panning.start(e.clientX, e.clientY);
if (this.panning.enabled) {
this.panning.start(e.clientX, e.clientY);
}
};
/**
......@@ -544,19 +634,7 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
* @param e
*/
private _mouse_mousemove = (e: MouseEvent) => {
if (this.panning.enabled) {
e.preventDefault();
e.stopPropagation();
this.panning.move(e.clientX, e.clientY);
} else {
// not panning
this.emit("hover", {
clientX: e.clientX,
clientY: e.clientY,
});
}
if (this.panning.enabled) {
if (this.panning.active) {
e.preventDefault();
e.stopPropagation();
......@@ -590,13 +668,20 @@ export class PanZoom extends EventEmitter<PanZoomEvents> {
// difference from the start position to the up position is very very slow,
// so it's most likely intended to be a click
this.emit("click", {
button: ["LCLICK", "MCLICK", "RCLICK"][e.button] as any,
clientX: e.clientX,
clientY: e.clientY,
alt: e.altKey,
ctrl: e.ctrlKey,
meta: e.metaKey,
shift: e.shiftKey,
});
}
}
if (this.panning.enabled) {
if (this.panning.active) {
// currently panning
e.preventDefault();
e.stopPropagation();
......
......@@ -2,7 +2,19 @@ import React, { useRef, useState, useEffect } from "react";
import { RendererContext } from "./RendererContext";
import { PanZoom } from "./PanZoom";
export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => {
export const PanZoomWrapper = ({
children,
initialPosition,
}: {
children: React.ReactNode;
initialPosition?: (useCssZoom: boolean) =>
| {
x: number;
y: number;
zoom?: number;
}
| undefined;
}) => {
const wrapper = useRef<HTMLDivElement>(null);
const zoom = useRef<HTMLDivElement>(null);
const move = useRef<HTMLDivElement>(null);
......@@ -15,6 +27,22 @@ export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => {
const $move = move.current;
if ($wrapper && $zoom && $move) {
if (initialPosition) {
const pos = initialPosition(instance.flags.useZoom);
if (pos) {
instance.setPosition(
{
x: pos.x,
y: pos.y,
zoom: pos.zoom || 0,
},
{ suppressEmit: true }
);
} else {
console.warn("Failed to set position via initialPosition; no pos");
}
}
instance.initialize($wrapper, $zoom, $move);
}
......
......@@ -3,7 +3,8 @@ import { PanZoom } from "../PanZoom";
export class Panning {
private instance: PanZoom;
public enabled: boolean = false;
public active: boolean = false;
public enabled: boolean = true;
public x: number = 0;
public y: number = 0;
......@@ -11,13 +12,23 @@ export class Panning {
this.instance = instance;
}
public setEnabled(enabled: boolean) {
// only trigger changes if an actual change was made
if (enabled === this.enabled) return;
this.enabled = enabled;
this.active = false;
this.instance.update();
}
/**
* trigger panning start
* @param x clientX
* @param y clientY
*/
public start(x: number, y: number) {
this.enabled = true;
this.active = true;
this.x = x;
this.y = y;
}
......@@ -45,7 +56,7 @@ export class Panning {
* @param y clientY
*/
public end(x: number, y: number) {
this.enabled = false;
this.active = false;
const deltaX = (x - this.x) / this.instance.transform.scale;
const deltaY = (y - this.y) / this.instance.transform.scale;
......
......@@ -17,7 +17,7 @@ export function handleCalculateZoomPositions(
const calculatedPositionX = x - mouseX * scaleDifference;
const calculatedPositionY = y - mouseY * scaleDifference;
contextInstance.debug(calculatedPositionX, calculatedPositionY, "zoom");
// Debug.debug("point", calculatedPositionX, calculatedPositionY, "zoom");
// do not limit to bounds when there is padding animation,
// it causes animation strange behaviour
......
# DO NOT USE
#
# @prisma/client for some reason will load .env whenever it's imported
# which makes tests impossible to do while this file exists
# every environment variable used in development should be placed in .env.local
#
# https://github.com/prisma/prisma/issues/15620
\ No newline at end of file
dist
\ No newline at end of file
......@@ -23,7 +23,19 @@
"plugins": ["@typescript-eslint"],
"rules": {
"no-console": "error",
"no-unused-vars": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-explicit-any": "off"
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrors": "all",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
]
}
}
node_modules
# Keep environment variables out of version control
.env
# .env is not actually used
# this file is included as a warning
-.env
......@@ -2,11 +2,13 @@
"name": "@sc07-canvas/server",
"version": "1.0.0",
"scripts": {
"dev": "DOTENV_CONFIG_PATH=.env.local nodemon -r dotenv/config src/index.ts",
"dev": "DOTENV_CONFIG_PATH=.env.local tsx watch -r dotenv/config src/index.ts",
"start": "node --enable-source-maps dist/index.js",
"profiler": "node --inspect=0.0.0.0:9229 --enable-source-maps dist/index.js",
"build": "tsc",
"lint": "eslint .",
"prisma:studio": "prisma studio",
"sentry": "sentry-cli sourcemaps inject dist && SENTRY_RELEASE=`sentry-cli releases propose-version` sentry-cli sourcemaps upload dist",
"lint": "eslint --format gitlab .",
"prisma:studio": "dotenv -v BROWSER=none -e .env.local -- prisma studio",
"prisma:migrate": "prisma migrate deploy",
"prisma:seed:palette": "./tool.sh seed_palette",
"tool": "./tool.sh"
......@@ -15,28 +17,32 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@tsconfig/recommended": "^1.0.2",
"@types/express": "^4.17.17",
"@types/express-session": "^1.17.7",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"dotenv": "^16.3.1",
"eslint": "^8.57.0",
"nodemon": "^3.0.1",
"prettier": "^3.0.1",
"prisma": "^5.3.1",
"@tsconfig/recommended": "^1.0.8",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.1",
"@types/uuid": "^10.0.0",
"dotenv": "^16.4.7",
"prettier": "^3.4.2",
"prisma": "^6.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
"typescript": "^5.7.2"
},
"dependencies": {
"@prisma/client": "^5.3.1",
"@prisma/client": "^6.1.0",
"@sc07-canvas/lib": "^1.0.0",
"connect-redis": "^7.1.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"prisma-dbml-generator": "^0.12.0",
"redis": "^4.6.12",
"socket.io": "^4.7.2",
"winston": "^3.11.0"
"@sentry/node": "^8.47.0",
"body-parser": "^1.20.2",
"connect-redis": "^8.0.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-session": "^1.18.1",
"openid-client": "^6.1.7",
"prom-client": "^15.1.3",
"rate-limit-redis": "^4.2.0",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
"winston": "^3.17.0"
}
}
......@@ -2,19 +2,53 @@
//// THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
//// ------------------------------------------------------
Table Setting {
key String [pk]
value String [not null]
}
Table User {
sub String [pk]
lastPixelTime DateTime [default: `now()`, not null]
display_name String
picture_url String
profile_url String
lastTimeGainStarted DateTime [default: `now()`, not null]
pixelStack Int [not null, default: 0]
undoExpires DateTime
isAdmin Boolean [not null, default: false]
isModerator Boolean [not null, default: false]
pixels Pixel [not null]
FactionMember FactionMember [not null]
Ban Ban
AuditLog AuditLog [not null]
IPAddress IPAddress [not null]
}
Table Instance {
id Int [pk, increment]
hostname String [unique, not null]
name String
logo_url String
banner_url String
Ban Ban
}
Table IPAddress {
ip String [not null]
userSub String [not null]
lastUsedAt DateTime [not null]
createdAt DateTime [default: `now()`, not null]
user User [not null]
indexes {
(ip, userSub) [pk]
}
}
Table PaletteColor {
id Int [pk, increment]
name String [not null]
hex String [unique, not null]
pixels Pixel [not null]
}
Table Pixel {
......@@ -23,9 +57,11 @@ Table Pixel {
x Int [not null]
y Int [not null]
color String [not null]
isTop Boolean [not null, default: false]
isModAction Boolean [not null, default: false]
createdAt DateTime [default: `now()`, not null]
deletedAt DateTime
user User [not null]
pallete PaletteColor [not null]
}
Table Faction {
......@@ -80,9 +116,51 @@ Table FactionSettingDefinition {
FactionSetting FactionSetting [not null]
}
Ref: Pixel.userId > User.sub
Table Ban {
id Int [pk, increment]
userId String [unique]
instanceId Int [unique]
privateNote String
publicNote String
expiresAt DateTime [not null]
createdAt DateTime [default: `now()`, not null]
updatedAt DateTime
user User
instance Instance
AuditLog AuditLog [not null]
}
Table AuditLog {
id Int [pk, increment]
userId String
action AuditLogAction [not null]
reason String
comment String
banId Int
createdAt DateTime [default: `now()`, not null]
updatedAt DateTime
user User
ban Ban
}
Enum AuditLogAction {
BAN_CREATE
BAN_UPDATE
BAN_DELETE
CANVAS_SIZE
CANVAS_FILL
CANVAS_FREEZE
CANVAS_UNFREEZE
CANVAS_AREA_UNDO
USER_MOD
USER_UNMOD
USER_ADMIN
USER_UNADMIN
}
Ref: IPAddress.userSub > User.sub
Ref: Pixel.color > PaletteColor.hex
Ref: Pixel.userId > User.sub
Ref: FactionMember.sub > User.sub
......@@ -94,4 +172,12 @@ Ref: FactionSocial.factionId > Faction.id
Ref: FactionSetting.key > FactionSettingDefinition.id
Ref: FactionSetting.factionId > Faction.id
\ No newline at end of file
Ref: FactionSetting.factionId > Faction.id
Ref: Ban.userId - User.sub
Ref: Ban.instanceId - Instance.id
Ref: AuditLog.userId > User.sub
Ref: AuditLog.banId > Ban.id
\ No newline at end of file
-- AlterTable
ALTER TABLE "User" ADD COLUMN "undoExpires" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isModerator" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);
-- AlterTable
ALTER TABLE "User" ADD COLUMN "display_name" TEXT,
ADD COLUMN "picture_url" TEXT,
ADD COLUMN "profile_url" TEXT;
-- CreateTable
CREATE TABLE "Instance" (
"id" SERIAL NOT NULL,
"hostname" TEXT NOT NULL,
"name" TEXT,
"logo_url" TEXT,
"banner_url" TEXT,
CONSTRAINT "Instance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Instance_hostname_key" ON "Instance"("hostname");
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "isModAction" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "isTop" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Pixel" ADD COLUMN "deletedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "Ban" (
"id" SERIAL NOT NULL,
"userId" TEXT,
"instanceId" INTEGER,
"privateNote" TEXT,
"publicNote" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ban_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Ban_userId_key" ON "Ban"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Ban_instanceId_key" ON "Ban"("instanceId");
-- AddForeignKey
ALTER TABLE "Ban" ADD CONSTRAINT "Ban_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ban" ADD CONSTRAINT "Ban_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "Instance"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateEnum
CREATE TYPE "AuditLogAction" AS ENUM ('BAN_CREATE', 'BAN_UPDATE', 'BAN_DELETE');
-- CreateTable
CREATE TABLE "AuditLog" (
"id" SERIAL NOT NULL,
"userId" TEXT,
"action" "AuditLogAction" NOT NULL,
"reason" TEXT,
"comment" TEXT,
"banId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3),
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("sub") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_banId_fkey" FOREIGN KEY ("banId") REFERENCES "Ban"("id") ON DELETE SET NULL ON UPDATE CASCADE;