first commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# https://editorconfig.org/
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = tab
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.y{,a}ml]
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[{Makefile,*.mk}]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
PORT=3000
|
||||||
|
|
||||||
|
DATABASE_URL="mysql://root:password@localhost/openplace"
|
||||||
|
JWT_SECRET="your-secret-key"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "chariz",
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"unicorn/switch-case-braces": ["error", "avoid"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.eslintcache
|
||||||
|
.pnpm-store/
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frontend"]
|
||||||
|
path = frontend
|
||||||
|
url = https://github.com/openplaceteam/frontend
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
https://*:8080 {
|
||||||
|
root * ./frontend
|
||||||
|
|
||||||
|
try_files {path} {path}/index.html
|
||||||
|
|
||||||
|
handle {
|
||||||
|
@file_exists file
|
||||||
|
handle @file_exists {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
@not_file_exists not file
|
||||||
|
handle @not_file_exists {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# openplace
|
||||||
|
Openplace (styled lowercase) is a free unofficial open source backend for [wplace.](https://wplace.live) We aim to give the freedom and flexibility for all users to be able to make their own private wplace experience for themselves, their friends, or even their community.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This is a work-in-progress. Expect unfinished features and bugs. Please help us by posting issues in #help-n-support on our [Discord server](https://discord.gg/ZRC4DnP9Z2) or by contributing pull requests. Thanks!
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
### Getting Started
|
||||||
|
This is where you will be preparing your machine to run openplace.
|
||||||
|
1. install brew, node and git
|
||||||
|
2. run `git clone --recurse-submodules https://github.com/openplaceteam/openplace`
|
||||||
|
3. cd into the openplace directory
|
||||||
|
4. run ``npm i && brew install mariadb caddy``
|
||||||
|
5. brew will then spit out a command to inform you on how to start it. if it doesn't, run `brew services start mariadb && brew services start caddy`
|
||||||
|
#### Configuring and building the database
|
||||||
|
1. run `sudo mysql_secure_installation`
|
||||||
|
2. it will then ask you for your current root password. just hit enter
|
||||||
|
3. hit 'n' when it asks you to switch to unix_socket authentication
|
||||||
|
4. hit 'y' when it asks you to change your root password. for demonstration purposes, i have made my password 'password'. (do not do this)
|
||||||
|
5. when it asks you to remove anonymous users, hit 'y'
|
||||||
|
6. it will ask you if you want to disallow remote root logins, this is entirely up to you.
|
||||||
|
7. hit 'y' when it asks you to remove the test database
|
||||||
|
8. finally, hit 'y' when it asks you to reload configuration.
|
||||||
|
9. copy the .env.example file on the root directory and rename it to .env where `root:password` is, replace `password` with your password.
|
||||||
|
10. you can now run `npx prisma migrate deploy`
|
||||||
|
11. next, `npx prisma generate`
|
||||||
|
12. NEXT, `npx prisma db push`
|
||||||
|
13. then you can run `npm run dev`
|
||||||
|
14. in another terminal, cd to the same root directory and run `caddy run --config Caddyfile`
|
||||||
|
|
||||||
|
#### Spinning up your server
|
||||||
|
You will be required to configure an SSL certificate if you plan to use this in production. However, if you are only using this with you and your friends, you can simply navigate to `https://{IP}:8080` NOTE: openplace is only hosted over HTTPS. you will run into HTTP error 400 if you attempt to load the website over HTTP.
|
||||||
|
|
||||||
|
#### Updating your database
|
||||||
|
In the event that the database schematic changes, you simply need to run `npm run db:push` to update your database schema.
|
||||||
Submodule
+1
Submodule frontend added at 3fc3dc12cb
Generated
+8801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "openplace",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist",
|
||||||
|
"seed": "tsx src/seed.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint --ext .js,.ts src/",
|
||||||
|
"lint:fix": "pnpm -s lint --fix",
|
||||||
|
"db:push": "prisma db push && prisma generate",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.1",
|
||||||
|
"@tsconfig/strictest": "^2.0.5",
|
||||||
|
"@types/node": "^24.3.1",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-chariz": "^1.6.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
|
"eslint-plugin-unicorn": "^47.0.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^15.5.2",
|
||||||
|
"supertest": "^7.1.4",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"pnpm": ">=10"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.80",
|
||||||
|
"@prisma/client": "^6.16.2",
|
||||||
|
"@tinyhttp/app": "^3.0.1",
|
||||||
|
"@tinyhttp/cookie-parser": "^2.0.6",
|
||||||
|
"@tinyhttp/cors": "^2.0.1",
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"mysql2": "^3.15.1",
|
||||||
|
"prisma": "^6.16.2",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
discord String?
|
||||||
|
country String
|
||||||
|
email String? @unique
|
||||||
|
passwordHash String
|
||||||
|
banned Boolean @default(false)
|
||||||
|
timeoutUntil DateTime @default(now())
|
||||||
|
needsPhoneVerification Boolean @default(false)
|
||||||
|
isCustomer Boolean @default(false)
|
||||||
|
role String @default("user")
|
||||||
|
pixelsPainted Int @default(0)
|
||||||
|
droplets Int @default(0)
|
||||||
|
maxCharges Float @default(20)
|
||||||
|
currentCharges Float @default(20)
|
||||||
|
chargesCooldownMs Int @default(30000)
|
||||||
|
chargesLastUpdatedAt DateTime @default(now())
|
||||||
|
extraColorsBitmap Int @default(0)
|
||||||
|
flagsBitmap Bytes?
|
||||||
|
equippedFlag Int @default(0)
|
||||||
|
showLastPixel Boolean @default(true)
|
||||||
|
maxFavoriteLocations Int @default(15)
|
||||||
|
picture String?
|
||||||
|
level Float @default(1)
|
||||||
|
allianceId Int?
|
||||||
|
allianceRole String @default("member")
|
||||||
|
alliance Alliance? @relation(fields: [allianceId], references: [id])
|
||||||
|
paintedPixels Pixel[]
|
||||||
|
favoriteLocations FavoriteLocation[]
|
||||||
|
createdTickets Ticket[] @relation("TicketUser")
|
||||||
|
reportedTickets Ticket[] @relation("TicketReportedUser")
|
||||||
|
createdNotes UserNote[] @relation("UserNoteUser")
|
||||||
|
reportedNotes UserNote[] @relation("UserNoteReportedUser")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Alliance {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
hqLatitude Float?
|
||||||
|
hqLongitude Float?
|
||||||
|
pixelsPainted Int @default(0)
|
||||||
|
members User[]
|
||||||
|
bannedUsers BannedUser[]
|
||||||
|
invites AllianceInvite[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model BannedUser {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
allianceId Int
|
||||||
|
alliance Alliance @relation(fields: [allianceId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([userId, allianceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AllianceInvite {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
allianceId Int
|
||||||
|
alliance Alliance @relation(fields: [allianceId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model FavoriteLocation {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
name String @default("")
|
||||||
|
latitude Float
|
||||||
|
longitude Float
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tile {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
x Int
|
||||||
|
y Int
|
||||||
|
imageData Bytes?
|
||||||
|
pixels Pixel[]
|
||||||
|
|
||||||
|
@@unique([x, y])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Pixel {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
tileX Int
|
||||||
|
tileY Int
|
||||||
|
x Int
|
||||||
|
y Int
|
||||||
|
colorId Int
|
||||||
|
paintedBy Int
|
||||||
|
user User @relation(fields: [paintedBy], references: [id])
|
||||||
|
tile Tile @relation(fields: [tileX, tileY], references: [x, y])
|
||||||
|
paintedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([tileX, tileY, x, y])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Region {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
cityId Int @unique
|
||||||
|
name String
|
||||||
|
number Int
|
||||||
|
countryId Int
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProfilePicture {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
url String
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId Int
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Ticket {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId Int
|
||||||
|
user User @relation("TicketUser", fields: [userId], references: [id])
|
||||||
|
reportedUserId Int
|
||||||
|
reportedUser User @relation("TicketReportedUser", fields: [reportedUserId], references: [id])
|
||||||
|
latitude Float
|
||||||
|
longitude Float
|
||||||
|
zoom Float
|
||||||
|
reason String
|
||||||
|
notes String
|
||||||
|
image String
|
||||||
|
resolved Boolean @default(false)
|
||||||
|
severe Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserNote {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
user User @relation("UserNoteUser", fields: [userId], references: [id])
|
||||||
|
reportedUserId Int
|
||||||
|
reportedUser User @relation("UserNoteReportedUser", fields: [reportedUserId], references: [id])
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
+2160
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
export const JWT_SECRET = process.env["JWT_SECRET"];
|
||||||
|
|
||||||
|
if (!JWT_SECRET) {
|
||||||
|
throw new Error("JWT_SECRET is not defined");
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export function addPrismaToRequest(req: any, _res: any, next: any) {
|
||||||
|
req.prisma = prisma;
|
||||||
|
return next?.();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface Region {
|
||||||
|
id: number;
|
||||||
|
cityId: number;
|
||||||
|
name: string;
|
||||||
|
number: number;
|
||||||
|
countryId: number;
|
||||||
|
flagId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegionForCoordinates(tileX: number, tileY: number, x: number, y: number): Region {
|
||||||
|
const globalX = tileX * 1000 + x;
|
||||||
|
const globalY = tileY * 1000 + y;
|
||||||
|
// TODO: implement region lookup
|
||||||
|
return {
|
||||||
|
id: 114_594,
|
||||||
|
cityId: 4263,
|
||||||
|
name: "Cupertino",
|
||||||
|
number: 2,
|
||||||
|
countryId: 235,
|
||||||
|
flagId: 235
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { cors } from "@tinyhttp/cors";
|
||||||
|
import { cookieParser } from "@tinyhttp/cookie-parser";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { inspect } from "util";
|
||||||
|
import admin from "./routes/admin.js";
|
||||||
|
import alliance from "./routes/alliance.js";
|
||||||
|
import auth from "./routes/auth.js";
|
||||||
|
import favoriteLocation from "./routes/favorite-location.js";
|
||||||
|
import leaderboard from "./routes/leaderboard.js";
|
||||||
|
import me from "./routes/me.js";
|
||||||
|
import moderator from "./routes/moderator.js";
|
||||||
|
import pixel from "./routes/pixel.js";
|
||||||
|
import store from "./routes/store.js";
|
||||||
|
import { addPrismaToRequest } from "./config/database.js";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
noMatchHandler: async (_req, res) => {
|
||||||
|
const html = await fs.readFile("./frontend/404.html", "utf-8");
|
||||||
|
return res.status(404)
|
||||||
|
.setHeader("Content-Type", "text/html")
|
||||||
|
.send(html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", chunk => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
req.body = body ? JSON.parse(body) : {};
|
||||||
|
} catch {
|
||||||
|
req.body = {};
|
||||||
|
}
|
||||||
|
return next?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const inspectOptions = { colors: true, compact: true, breakLength: Number.POSITIVE_INFINITY };
|
||||||
|
const startTime = Date.now();
|
||||||
|
const requestId = req.get("x-forwarded-for");
|
||||||
|
|
||||||
|
console.log(`[${requestId}] [${new Date()
|
||||||
|
.toISOString()}] ${req.method} ${req.url}`);
|
||||||
|
console.log(`[${requestId}] Headers:`, inspect(req.headers, inspectOptions));
|
||||||
|
if (req.body && Object.keys(req.body).length > 0) {
|
||||||
|
console.log(`[${requestId}] Body:`, inspect(req.body, inspectOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalJson = res.json;
|
||||||
|
res.json = function (data) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.log(`[${requestId}] Response JSON (${res.statusCode}) [${duration}ms]:`, inspect(data, inspectOptions));
|
||||||
|
return originalJson.call(this, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return next?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(addPrismaToRequest);
|
||||||
|
|
||||||
|
admin(app);
|
||||||
|
alliance(app);
|
||||||
|
auth(app);
|
||||||
|
favoriteLocation(app);
|
||||||
|
leaderboard(app);
|
||||||
|
me(app);
|
||||||
|
moderator(app);
|
||||||
|
pixel(app);
|
||||||
|
store(app);
|
||||||
|
|
||||||
|
const PORT = Number(process.env["PORT"]) || 3000;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { JWT_SECRET } from "../config/auth.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
export async function authMiddleware(req: any, res: any, next: any) {
|
||||||
|
try {
|
||||||
|
const token = req.cookies?.j;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||||
|
|
||||||
|
if (!decoded.userId || !decoded.sessionId) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
|
||||||
|
return res.status(500)
|
||||||
|
.json({
|
||||||
|
error: "Internal Server Error. We'll look into it, please try again later.",
|
||||||
|
status: 500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { id: decoded.sessionId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.userId !== decoded.userId || session.expiresAt < new Date()) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: decoded.userId,
|
||||||
|
sessionId: decoded.sessionId
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createErrorResponse, ERROR_MESSAGES, HTTP_STATUS } from "../utils/response.js";
|
||||||
|
|
||||||
|
export function handleServiceError(error: Error, res: any) {
|
||||||
|
console.error("Service error:", error);
|
||||||
|
|
||||||
|
switch (error.message) {
|
||||||
|
case "Bad Request":
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.BAD_REQUEST, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
|
||||||
|
case "User not found":
|
||||||
|
return res.status(HTTP_STATUS.NOT_FOUND)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.USER_NOT_FOUND, HTTP_STATUS.NOT_FOUND));
|
||||||
|
|
||||||
|
case "No Alliance":
|
||||||
|
return res.status(HTTP_STATUS.NOT_FOUND)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.NO_ALLIANCE, HTTP_STATUS.NOT_FOUND));
|
||||||
|
|
||||||
|
case "Forbidden":
|
||||||
|
return res.status(HTTP_STATUS.FORBIDDEN)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.FORBIDDEN, HTTP_STATUS.FORBIDDEN));
|
||||||
|
|
||||||
|
case "refresh":
|
||||||
|
return res.status(HTTP_STATUS.UNAUTHORIZED)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.REFRESH_TOKEN, HTTP_STATUS.UNAUTHORIZED));
|
||||||
|
|
||||||
|
case "banned":
|
||||||
|
return res.status(HTTP_STATUS.UNAVAILABLE_FOR_LEGAL_REASONS)
|
||||||
|
.json({ err: "other", suspension: "ban" });
|
||||||
|
|
||||||
|
case "attempted to paint more pixels than there was charges.":
|
||||||
|
return res.status(HTTP_STATUS.FORBIDDEN)
|
||||||
|
.json(createErrorResponse(error.message, HTTP_STATUS.FORBIDDEN));
|
||||||
|
|
||||||
|
case "attempted to paint with a colour that was not purchased.":
|
||||||
|
return res.status(HTTP_STATUS.FORBIDDEN)
|
||||||
|
.json(createErrorResponse(error.message, HTTP_STATUS.FORBIDDEN));
|
||||||
|
|
||||||
|
case "The name has more than 16 characters":
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(error.message, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
|
||||||
|
case "Alliance name is required":
|
||||||
|
case "Alliance name taken":
|
||||||
|
case "Already in alliance":
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(error.message, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
|
||||||
|
case "Invalid invite":
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.INVALID_INVITE, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
|
||||||
|
case "Not Found":
|
||||||
|
return res.status(HTTP_STATUS.NOT_FOUND)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.NOT_FOUND, HTTP_STATUS.NOT_FOUND));
|
||||||
|
|
||||||
|
case "Already Reported":
|
||||||
|
return res.status(HTTP_STATUS.ALREADY_REPORTED)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.ALREADY_REPORTED, HTTP_STATUS.ALREADY_REPORTED));
|
||||||
|
|
||||||
|
default:
|
||||||
|
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
|
||||||
|
.json(createErrorResponse(ERROR_MESSAGES.INTERNAL_SERVER_ERROR, HTTP_STATUS.INTERNAL_SERVER_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - openplace</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 100px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.login-form {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10%;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #0069ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-form">
|
||||||
|
<h1>Welcome back!</h1>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="username" name="username" placeholder="Username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="password" name="password" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("loginForm").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById("username").value;
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
const errorDiv = document.getElementById("error");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = "/";
|
||||||
|
} else {
|
||||||
|
errorDiv.textContent = data.error || "Login failed";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = "Network error occurred";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import { App, NextFunction, Response } from "@tinyhttp/app";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { AuthenticatedRequest, UserRole } from "../types/index.js";
|
||||||
|
import { Prisma, Ticket } from "@prisma/client";
|
||||||
|
|
||||||
|
const REPORT_REASONS = [
|
||||||
|
{ key: "doxxing", label: "Doxxing" },
|
||||||
|
{ key: "inappropriate_content", label: "Inappropriate Content" },
|
||||||
|
{ key: "hate_speech", label: "Hate Speech" },
|
||||||
|
{ key: "bot", label: "Bot" },
|
||||||
|
{ key: "other", label: "Other" },
|
||||||
|
{ key: "griefing", label: "Griefing" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminMiddleware = async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role !== UserRole.Admin) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
return next?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/admin/users", authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.query["id"] as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(id) || id <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const alliance = user.allianceId
|
||||||
|
? await prisma.alliance.findFirst({
|
||||||
|
where: { id: user.allianceId }
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
droplets: user.droplets,
|
||||||
|
picture: user.picture,
|
||||||
|
role: user.role,
|
||||||
|
timeout_until: user.timeoutUntil,
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
ban_reason: null,
|
||||||
|
reported_times: 0,
|
||||||
|
timeouts_count: 0,
|
||||||
|
|
||||||
|
same_ip_accounts: 0,
|
||||||
|
alliance_id: user.allianceId,
|
||||||
|
alliance_name: alliance?.name,
|
||||||
|
pixels_painted: user.pixelsPainted,
|
||||||
|
phone_validated: false,
|
||||||
|
discord: user.discord
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/users/notes", authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.query["userId"] as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(id) || id <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = await prisma.userNote.findMany({
|
||||||
|
where: { reportedUserId: id },
|
||||||
|
include: { user: true },
|
||||||
|
orderBy: { createdAt: "desc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({
|
||||||
|
notes: notes.map(note => ({
|
||||||
|
id: note.id,
|
||||||
|
admin: {
|
||||||
|
id: note.user.id,
|
||||||
|
name: note.user.name
|
||||||
|
},
|
||||||
|
content: note.content,
|
||||||
|
createdAt: note.createdAt
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notes:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/users/notes", authMiddleware, adminMiddleware, async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.body["userId"] as string ?? "") || 0;
|
||||||
|
const note = req.body["note"];
|
||||||
|
if (Number.isNaN(id) || id <= 0 || typeof note !== "string") {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userNote.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.id,
|
||||||
|
reportedUserId: id,
|
||||||
|
content: note
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching notes:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/users/tickets", authMiddleware, adminMiddleware, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.query["id"] as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(id) || id <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
return res.status(200)
|
||||||
|
.json({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/users/purchases", authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.query["userId"] as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(id) || id <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
return res.status(200)
|
||||||
|
.json({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching purchases:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/users/set-user-droplets", authMiddleware, adminMiddleware, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = Number.parseInt(req.body["userId"] as string ?? "") || 0;
|
||||||
|
const droplets = Number.parseInt(req.body["droplets"] as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(userId) || userId <= 0 || Number.isNaN(droplets)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDroplets = user.droplets + droplets;
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { droplets: newDroplets }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting user droplets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(["/admin/tickets", "/admin/closed-tickets"], authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role === UserRole.User) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = req.path === "/admin/closed-tickets";
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where: { resolved },
|
||||||
|
orderBy: { createdAt: "desc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all reported users
|
||||||
|
const reportedUserIds = tickets.map(ticket => ticket.reportedUserId);
|
||||||
|
const reportedUsers = await prisma.user.findMany({
|
||||||
|
where: { id: { in: reportedUserIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
discord: true,
|
||||||
|
country: true,
|
||||||
|
banned: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(reportedUsers.map(user => [user.id, user]));
|
||||||
|
|
||||||
|
// Group tickets by reported user
|
||||||
|
const ticketsByUser = new Map<number, Ticket[]>();
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const userId = ticket.reportedUserId;
|
||||||
|
if (!ticketsByUser.has(userId)) {
|
||||||
|
ticketsByUser.set(userId, []);
|
||||||
|
}
|
||||||
|
ticketsByUser.get(userId)!.push(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTickets = [...ticketsByUser.entries()].map(([userId, userTickets]) => {
|
||||||
|
const reportedUser = userMap.get(userId);
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
reportedUser: reportedUser
|
||||||
|
? {
|
||||||
|
id: reportedUser.id,
|
||||||
|
name: reportedUser.name,
|
||||||
|
discord: reportedUser.discord || "",
|
||||||
|
country: reportedUser.country,
|
||||||
|
banned: reportedUser.banned
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: userTickets[0]?.createdAt,
|
||||||
|
reports: userTickets.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
latitude: ticket.latitude,
|
||||||
|
longitude: ticket.longitude,
|
||||||
|
zoom: ticket.zoom,
|
||||||
|
reason: ticket.reason,
|
||||||
|
notes: ticket.notes,
|
||||||
|
image: ticket.image,
|
||||||
|
createdAt: ticket.createdAt
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: formattedTickets, status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching moderator tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/open-tickets-count", authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role === UserRole.User) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await prisma.ticket.count({
|
||||||
|
where: { resolved: false }
|
||||||
|
});
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/severe-open-tickets-count", authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role === UserRole.User) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await prisma.ticket.count({
|
||||||
|
where: {
|
||||||
|
severe: true,
|
||||||
|
resolved: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/assign-new-tickets", authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role === UserRole.User) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
res.json({
|
||||||
|
newTicketsIds: []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/count-all-tickets", authMiddleware, adminMiddleware, async (_req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const results = await prisma.ticket.groupBy({
|
||||||
|
by: ["reason"],
|
||||||
|
where: {
|
||||||
|
resolved: false
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
reason: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasons = new Map<string, number>(REPORT_REASONS.map(item => [item.key, 0]));
|
||||||
|
for (const item of results) {
|
||||||
|
reasons.set(item.reason, item._count.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = [...reasons.values()].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({
|
||||||
|
...Object.fromEntries(reasons),
|
||||||
|
total_open_tickets: totalCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/count-all-reports", authMiddleware, adminMiddleware, async (_req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
// TODO: Might need a separate table?
|
||||||
|
const results = await prisma.ticket.groupBy({
|
||||||
|
by: ["reason"],
|
||||||
|
where: {
|
||||||
|
resolved: false
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
reason: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const reasons = new Map<string, number>(REPORT_REASONS.map(item => [item.key, 0]));
|
||||||
|
for (const item of results) {
|
||||||
|
reasons.set(item.reason, item._count.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = [...reasons.values()].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({
|
||||||
|
...Object.fromEntries(reasons),
|
||||||
|
total_open_reports: totalCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(["/admin/alliances/:id", "/admin/alliances/:id/full"], authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const id = Number.parseInt(req.params["id"] as string ?? "");
|
||||||
|
if (Number.isNaN(id) || id <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFull = req.path.endsWith("/full");
|
||||||
|
const alliance = await prisma.alliance.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: isFull
|
||||||
|
? {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
hqLatitude: true,
|
||||||
|
hqLongitude: true,
|
||||||
|
pixelsPainted: true,
|
||||||
|
members: true,
|
||||||
|
bannedUsers: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
pixelsPainted: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!alliance) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "Alliance not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Owner field
|
||||||
|
const owner = alliance.members?.[0];
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...alliance,
|
||||||
|
membersCount: alliance.members?.length || 0,
|
||||||
|
ownerId: owner?.id,
|
||||||
|
ownerName: owner?.name
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/alliances/search", authMiddleware, adminMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const query = req.query["q"] as string ?? "";
|
||||||
|
const queryId = Number.parseInt(query) || 0;
|
||||||
|
|
||||||
|
let where: Prisma.AllianceWhereInput = {
|
||||||
|
name: {
|
||||||
|
contains: query
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Number.isNaN(queryId) && queryId !== 0) {
|
||||||
|
where = {
|
||||||
|
id: queryId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await prisma.alliance.findMany({
|
||||||
|
where,
|
||||||
|
take: 20,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
pixelsPainted: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { handleServiceError } from "../middleware/errorHandler.js";
|
||||||
|
import { AllianceService } from "../services/alliance.js";
|
||||||
|
import { validatePaginationPage } from "../validators/common.js";
|
||||||
|
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
const allianceService = new AllianceService(prisma);
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/alliance", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const result = await allianceService.getUserAlliance(req.user!.id);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
const result = await allianceService.createAlliance(req.user!.id, { name });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance/update-description", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { description } = req.body;
|
||||||
|
const result = await allianceService.updateDescription(req.user!.id, { description });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/alliance/invites", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const result = await allianceService.getInvites(req.user!.id);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/alliance/join/:invite", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { invite } = req.params;
|
||||||
|
const result = await allianceService.joinAlliance(req.user!.id, invite);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance/update-headquarters", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { latitude, longitude } = req.body;
|
||||||
|
const result = await allianceService.updateHeadquarters(req.user!.id, { latitude, longitude });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/alliance/members/:page", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const page = Number.parseInt(req.params.page) || 0;
|
||||||
|
|
||||||
|
if (!validatePaginationPage(page)) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await allianceService.getMembers(req.user!.id, page);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/alliance/members/banned/:page", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const page = Number.parseInt(req.params.page) || 0;
|
||||||
|
|
||||||
|
if (!validatePaginationPage(page)) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await allianceService.getBannedMembers(req.user!.id, page);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance/give-admin", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { promotedUserId } = req.body;
|
||||||
|
await allianceService.promoteUser(req.user!.id, promotedUserId);
|
||||||
|
return res.status(HTTP_STATUS.OK)
|
||||||
|
.send();
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance/ban", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { bannedUserId } = req.body;
|
||||||
|
const result = await allianceService.banUser(req.user!.id, bannedUserId);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/alliance/unban", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { unbannedUserId } = req.body;
|
||||||
|
const result = await allianceService.unbanUser(req.user!.id, unbannedUserId);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/alliance/leaderboard/:mode", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { mode } = req.params;
|
||||||
|
console.log(`Fetching leaderboard for mode: ${mode}`);
|
||||||
|
|
||||||
|
const result = await allianceService.getLeaderboard(req.user!.id, mode);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { JWT_SECRET } from "../config/auth.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/login", async (_req, res) => {
|
||||||
|
const loginHtml = await fs.readFile("./src/public/login.html", "utf8");
|
||||||
|
res.setHeader("Content-Type", "text/html");
|
||||||
|
return res.send(loginHtml);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/login", async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Username and password required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await prisma.user.findFirst({
|
||||||
|
where: { name: username }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const passwordValid = await bcrypt.compare(password, user.passwordHash ?? "");
|
||||||
|
if (!passwordValid) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Invalid username or password" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: username,
|
||||||
|
passwordHash,
|
||||||
|
country: "US", // TODO
|
||||||
|
droplets: 1000,
|
||||||
|
currentCharges: 20,
|
||||||
|
maxCharges: 20,
|
||||||
|
pixelsPainted: 0,
|
||||||
|
level: 1,
|
||||||
|
extraColorsBitmap: 0,
|
||||||
|
equippedFlag: 0,
|
||||||
|
chargesLastUpdatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
iss: "openplace",
|
||||||
|
exp: Math.floor(session.expiresAt.getTime() / 1000),
|
||||||
|
iat: Math.floor(Date.now() / 1000)
|
||||||
|
},
|
||||||
|
JWT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", [
|
||||||
|
`j=${token}; HttpOnly; Path=/; Max-Age=${30 * 24 * 60 * 60}; SameSite=Lax`
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/auth/logout", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
if (req.user?.sessionId) {
|
||||||
|
await prisma.session.delete({
|
||||||
|
where: { id: req.user.sessionId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", [
|
||||||
|
`j=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.post("/favorite-location", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { latitude, longitude, name } = req.body;
|
||||||
|
|
||||||
|
if (typeof latitude !== "number" || typeof longitude !== "number") {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid coordinates", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
include: { favoriteLocations: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.favoriteLocations.length >= user.maxFavoriteLocations) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Maximum favorite locations reached", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorite = await prisma.favoriteLocation.create({
|
||||||
|
data: {
|
||||||
|
userId: req.user!.id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
name: name || ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
id: favorite.id,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding favorite location:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/favorite-location/delete", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const locationId = Number.parseInt(req.body.id);
|
||||||
|
|
||||||
|
if (Number.isNaN(locationId)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid location ID", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await prisma.favoriteLocation.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: locationId,
|
||||||
|
userId: req.user!.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "Favorite location not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting favorite location:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
const validModes = new Set(["today", "week", "month", "all-time"]);
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/leaderboard/region/:mode/:country", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mode, country } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = await prisma.region.findMany({
|
||||||
|
where: {
|
||||||
|
countryId: Number.parseInt(country || "0")
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
const regionsWithStats = await Promise.all(
|
||||||
|
regions.map(async (region) => {
|
||||||
|
return {
|
||||||
|
id: region.id,
|
||||||
|
name: region.name,
|
||||||
|
cityId: region.cityId,
|
||||||
|
number: region.number,
|
||||||
|
countryId: region.countryId,
|
||||||
|
pixelsPainted: 1234,
|
||||||
|
lastLatitude: 0,
|
||||||
|
lastLongitude: 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
regionsWithStats.sort((a, b) => b.pixelsPainted - a.pixelsPainted);
|
||||||
|
|
||||||
|
return res.json(regionsWithStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching region leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/leaderboard/country/:mode", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mode } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
// TODO: calculate country pixel data
|
||||||
|
const mockCountries = [
|
||||||
|
{ id: 235, pixelsPainted: 1_234 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return res.json(mockCountries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching country leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/leaderboard/player/:mode", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mode } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFilter = {};
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "today": {
|
||||||
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
dateFilter = { paintedAt: { gte: startOfDay } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "week": {
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - 7);
|
||||||
|
dateFilter = { paintedAt: { gte: startOfWeek } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "month": {
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
dateFilter = { paintedAt: { gte: startOfMonth } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "all-time") {
|
||||||
|
const players = await prisma.user.findMany({
|
||||||
|
orderBy: { pixelsPainted: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
allianceId: true,
|
||||||
|
equippedFlag: true,
|
||||||
|
pixelsPainted: true,
|
||||||
|
picture: true,
|
||||||
|
discord: true,
|
||||||
|
alliance: {
|
||||||
|
select: { name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = players.map(player => ({
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
allianceId: player.allianceId || 0,
|
||||||
|
allianceName: player.alliance?.name || "",
|
||||||
|
equippedFlag: player.equippedFlag,
|
||||||
|
pixelsPainted: player.pixelsPainted,
|
||||||
|
picture: player.picture || undefined,
|
||||||
|
discord: player.discord || ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json(response);
|
||||||
|
} else {
|
||||||
|
// Count pixels by time period
|
||||||
|
const pixelCounts = await prisma.pixel.groupBy({
|
||||||
|
by: ["paintedBy"],
|
||||||
|
_count: { id: true },
|
||||||
|
where: dateFilter,
|
||||||
|
orderBy: { _count: { id: "desc" } },
|
||||||
|
take: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const userIds = pixelCounts.map(p => p.paintedBy);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
allianceId: true,
|
||||||
|
equippedFlag: true,
|
||||||
|
picture: true,
|
||||||
|
discord: true,
|
||||||
|
alliance: {
|
||||||
|
select: { name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
const response = pixelCounts.map(pixel => {
|
||||||
|
const user = userMap.get(pixel.paintedBy);
|
||||||
|
return {
|
||||||
|
id: user?.id || pixel.paintedBy,
|
||||||
|
name: user?.name || "Unknown",
|
||||||
|
allianceId: user?.allianceId || 0,
|
||||||
|
allianceName: user?.alliance?.name || "",
|
||||||
|
equippedFlag: user?.equippedFlag || 0,
|
||||||
|
pixelsPainted: pixel._count.id,
|
||||||
|
picture: user?.picture || undefined,
|
||||||
|
discord: user?.discord || ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching player leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/leaderboard/alliance/:mode", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { mode } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFilter = {};
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "today": {
|
||||||
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
dateFilter = { paintedAt: { gte: startOfDay } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "week": {
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(now.getDate() - 7);
|
||||||
|
dateFilter = { paintedAt: { gte: startOfWeek } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "month": {
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
dateFilter = { paintedAt: { gte: startOfMonth } };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "all-time") {
|
||||||
|
const alliances = await prisma.alliance.findMany({
|
||||||
|
orderBy: { pixelsPainted: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
pixelsPainted: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(alliances);
|
||||||
|
} else {
|
||||||
|
// Count pixels by time period and alliance
|
||||||
|
const pixelCounts = await prisma.pixel.groupBy({
|
||||||
|
by: ["paintedBy"],
|
||||||
|
_count: { id: true },
|
||||||
|
where: dateFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user alliance memberships
|
||||||
|
const userIds = pixelCounts.map(p => p.paintedBy);
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: userIds },
|
||||||
|
allianceId: { not: null }
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
allianceId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group pixels by alliance
|
||||||
|
const alliancePixelCounts = new Map<number, number>();
|
||||||
|
const userAllianceMap = new Map(users.map(u => [u.id, u.allianceId!]));
|
||||||
|
|
||||||
|
for (const pixel of pixelCounts) {
|
||||||
|
const allianceId = userAllianceMap.get(pixel.paintedBy);
|
||||||
|
if (allianceId) {
|
||||||
|
const currentCount = alliancePixelCounts.get(allianceId) || 0;
|
||||||
|
alliancePixelCounts.set(allianceId, currentCount + pixel._count.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get alliance details and sort by pixel count
|
||||||
|
const allianceIds = [...alliancePixelCounts.keys()];
|
||||||
|
const alliances = await prisma.alliance.findMany({
|
||||||
|
where: { id: { in: allianceIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = alliances.map(alliance => ({
|
||||||
|
id: alliance.id,
|
||||||
|
name: alliance.name,
|
||||||
|
pixelsPainted: alliancePixelCounts.get(alliance.id) || 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.pixelsPainted - a.pixelsPainted)
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
|
return res.json(response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching alliance leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/leaderboard/region/players/:city/:mode", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// TODO: city not being used
|
||||||
|
const { city: _city, mode } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = await prisma.user.findMany({
|
||||||
|
orderBy: { pixelsPainted: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
allianceId: true,
|
||||||
|
equippedFlag: true,
|
||||||
|
pixelsPainted: true,
|
||||||
|
picture: true,
|
||||||
|
discord: true,
|
||||||
|
alliance: {
|
||||||
|
select: { name: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = players.map(player => ({
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
allianceId: player.allianceId || 0,
|
||||||
|
allianceName: player.alliance?.name || "",
|
||||||
|
pixelsPainted: player.pixelsPainted,
|
||||||
|
equippedFlag: player.equippedFlag,
|
||||||
|
picture: player.picture || undefined,
|
||||||
|
discord: player.discord || ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching regional players leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/leaderboard/region/alliances/:city/:mode", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// TODO: city not being used
|
||||||
|
const { city: _city, mode } = req.params;
|
||||||
|
|
||||||
|
if (!mode || !validModes.has(mode)) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid mode", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const alliances = await prisma.alliance.findMany({
|
||||||
|
orderBy: { pixelsPainted: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
pixelsPainted: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(alliances);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching regional alliances leaderboard:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { handleServiceError } from "../middleware/errorHandler.js";
|
||||||
|
import { UserService } from "../services/user.js";
|
||||||
|
import { validateUpdateUser } from "../validators/user.js";
|
||||||
|
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
const userService = new UserService(prisma);
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/me", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const result = await userService.getUserProfile(req.user!.id);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/me/update", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { name, showLastPixel, discord } = req.body;
|
||||||
|
|
||||||
|
const validationError = validateUpdateUser({ name, showLastPixel, discord });
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await userService.updateUser(req.user!.id, { name, showLastPixel, discord });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/me/profile-pictures", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const result = await userService.getProfilePictures(req.user!.id);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { App, NextFunction, Response } from "@tinyhttp/app";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { AuthenticatedRequest, UserRole } from "../types/index.js";
|
||||||
|
import { Ticket, User } from "@prisma/client";
|
||||||
|
|
||||||
|
const moderatorMiddleware = async (req: AuthenticatedRequest, res: Response, next?: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id }
|
||||||
|
});
|
||||||
|
if (!user || user.role === UserRole.User) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
return next?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/moderator/tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where: {
|
||||||
|
resolved: false
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
discord: true,
|
||||||
|
country: true,
|
||||||
|
banned: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reportedUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
discord: true,
|
||||||
|
country: true,
|
||||||
|
banned: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all reported users
|
||||||
|
const reportedUserIds = tickets.map(ticket => ticket.reportedUserId);
|
||||||
|
const reportedUsers = await prisma.user.findMany({
|
||||||
|
where: { id: { in: reportedUserIds } },
|
||||||
|
select: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(reportedUsers.map(user => [user.id, user]));
|
||||||
|
|
||||||
|
// Group tickets by reported user
|
||||||
|
const ticketsByUser = new Map<number, Ticket[]>();
|
||||||
|
const authors = new Map<number, User | null>();
|
||||||
|
await Promise.all(tickets.map(async ticket => {
|
||||||
|
const userId = ticket.userId;
|
||||||
|
if (!authors.has(userId)) {
|
||||||
|
authors.set(userId, await prisma.user.findFirst({
|
||||||
|
where: { id: userId }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportedUserID = ticket.reportedUserId;
|
||||||
|
if (!ticketsByUser.has(reportedUserID)) {
|
||||||
|
ticketsByUser.set(reportedUserID, []);
|
||||||
|
}
|
||||||
|
ticketsByUser.get(reportedUserID)!.push(ticket);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedTickets = [...ticketsByUser.entries()].map(([userId, userTickets]) => {
|
||||||
|
const author = authors.get(userTickets[0]?.userId || 0);
|
||||||
|
const reportedUser = userMap.get(userId);
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
author: author
|
||||||
|
? {
|
||||||
|
id: author.id,
|
||||||
|
name: author.name,
|
||||||
|
discord: author.discord || "",
|
||||||
|
country: author.country,
|
||||||
|
banned: author.banned
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
reportedUser: reportedUser
|
||||||
|
? {
|
||||||
|
id: reportedUser.id,
|
||||||
|
name: reportedUser.name,
|
||||||
|
discord: reportedUser.discord || "",
|
||||||
|
country: reportedUser.country,
|
||||||
|
banned: reportedUser.banned
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: userTickets[0]?.createdAt,
|
||||||
|
reports: userTickets.map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
latitude: ticket.latitude,
|
||||||
|
longitude: ticket.longitude,
|
||||||
|
zoom: ticket.zoom,
|
||||||
|
reason: ticket.reason,
|
||||||
|
notes: ticket.notes,
|
||||||
|
image: ticket.image,
|
||||||
|
createdAt: ticket.createdAt
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: formattedTickets, status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching moderator tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/moderator/users/tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const userId = Number.parseInt(req.query.userId as string ?? "") || 0;
|
||||||
|
if (Number.isNaN(userId) || userId <= 0) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportedUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
discord: true,
|
||||||
|
country: true,
|
||||||
|
banned: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!reportedUser) {
|
||||||
|
return res.status(404)
|
||||||
|
.json({ error: "User not found", status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where: { reportedUserId: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
const authors = new Map<number, User | null>();
|
||||||
|
await Promise.all(tickets.map(async ticket => {
|
||||||
|
const userId = ticket.userId;
|
||||||
|
if (!authors.has(userId)) {
|
||||||
|
authors.set(userId, await prisma.user.findFirst({
|
||||||
|
where: { id: userId }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formattedTickets = tickets.map(ticket => {
|
||||||
|
const author = authors.get(ticket.userId);
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
author: author
|
||||||
|
? {
|
||||||
|
id: author.id,
|
||||||
|
name: author.name,
|
||||||
|
discord: author.discord || "",
|
||||||
|
country: author.country,
|
||||||
|
banned: author.banned
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
reportedUser: reportedUser
|
||||||
|
? {
|
||||||
|
id: reportedUser.id,
|
||||||
|
name: reportedUser.name,
|
||||||
|
discord: reportedUser.discord || "",
|
||||||
|
country: reportedUser.country,
|
||||||
|
banned: reportedUser.banned
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: ticket.createdAt,
|
||||||
|
reports: [ticket].map(ticket => ({
|
||||||
|
id: ticket.id,
|
||||||
|
latitude: ticket.latitude,
|
||||||
|
longitude: ticket.longitude,
|
||||||
|
zoom: ticket.zoom,
|
||||||
|
reason: ticket.reason,
|
||||||
|
notes: ticket.notes,
|
||||||
|
image: ticket.image,
|
||||||
|
createdAt: ticket.createdAt
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: formattedTickets, status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching moderator tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/moderator/open-tickets-count", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const count = await prisma.ticket.count({
|
||||||
|
where: { resolved: false }
|
||||||
|
});
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/moderator/severe-open-tickets-count", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const count = await prisma.ticket.count({
|
||||||
|
where: {
|
||||||
|
severe: true,
|
||||||
|
resolved: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.status(200)
|
||||||
|
.json({ tickets: count });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/moderator/assign-new-tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
// TODO
|
||||||
|
res.json({
|
||||||
|
newTicketsIds: []
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/moderator/count-my-tickets", authMiddleware, moderatorMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
// TODO
|
||||||
|
res.json(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error assigning new tickets:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { handleServiceError } from "../middleware/errorHandler.js";
|
||||||
|
import { PixelService } from "../services/pixel.js";
|
||||||
|
import { validateSeason, validateTileCoordinates } from "../validators/common.js";
|
||||||
|
import { validatePaintPixels, validatePixelInfo } from "../validators/pixel.js";
|
||||||
|
import { createErrorResponse, HTTP_STATUS } from "../utils/response.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
const pixelService = new PixelService(prisma);
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.get("/:season/tile/random", async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const season = req.params["season"];
|
||||||
|
if (!validateSeason(season)) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pixelService.getRandomTile();
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting random tile:", error);
|
||||||
|
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
|
||||||
|
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:season/pixel/:tileX/:tileY", async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const season = req.params["season"];
|
||||||
|
const tileX = Number.parseInt(req.params["tileX"]);
|
||||||
|
const tileY = Number.parseInt(req.params["tileY"]);
|
||||||
|
const x = Number.parseInt(req.query["x"] as string);
|
||||||
|
const y = Number.parseInt(req.query["y"] as string);
|
||||||
|
|
||||||
|
const validationError = validatePixelInfo({ season, tileX, tileY, x, y });
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pixelService.getPixelInfo(tileX, tileY, x, y);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting pixel info:", error);
|
||||||
|
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
|
||||||
|
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/files/:season/tiles/:tileX/:tileY.png", async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const season = req.params["season"];
|
||||||
|
const tileX = Number.parseInt(req.params["tileX"]);
|
||||||
|
const tileY = Number.parseInt(req.params["tileY"]);
|
||||||
|
|
||||||
|
if (!validateSeason(season)) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateTileCoordinates(tileX, tileY)) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse("Bad Request", HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = await pixelService.generateTileImage(tileX, tileY);
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "image/png");
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
return res.send(imageBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating tile image:", error);
|
||||||
|
return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR)
|
||||||
|
.json(createErrorResponse("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:season/pixel/:tileX/:tileY", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const season = req.params["season"];
|
||||||
|
const tileX = Number.parseInt(req.params["tileX"]);
|
||||||
|
const tileY = Number.parseInt(req.params["tileY"]);
|
||||||
|
const { colors, coords } = req.body;
|
||||||
|
|
||||||
|
const validationError = validatePaintPixels({ season, tileX, tileY, colors, coords });
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(HTTP_STATUS.BAD_REQUEST)
|
||||||
|
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pixelService.paintPixels(req.user!.id, { tileX, tileY, colors, coords });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return handleServiceError(error as Error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { App } from "@tinyhttp/app";
|
||||||
|
import { WplaceBitMap } from "../utils/bitmap.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { prisma } from "../config/database.js";
|
||||||
|
|
||||||
|
const STORE_ITEMS = {
|
||||||
|
70: { name: "+5 Max. Charges", price: 500, type: "charges" },
|
||||||
|
80: { name: "+30 Paint Charges", price: 500, type: "paint" },
|
||||||
|
100: { name: "Unlock Paid Colors", price: 2000, type: "color" },
|
||||||
|
110: { name: "Unlock Flag", price: 20_000, type: "flag" }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function (app: App) {
|
||||||
|
app.post("/purchase", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { product } = req.body;
|
||||||
|
|
||||||
|
if (!product || !product.id) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = STORE_ITEMS[product.id as keyof typeof STORE_ITEMS];
|
||||||
|
if (!item) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Invalid item", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost = item.price * (product.amount || 1);
|
||||||
|
|
||||||
|
if (user.droplets < totalCost) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
droplets: user.droplets - totalCost
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case "charges":
|
||||||
|
updateData.maxCharges = user.maxCharges + (5 * (product.amount || 1));
|
||||||
|
break;
|
||||||
|
case "paint":
|
||||||
|
updateData.currentCharges = Math.min(
|
||||||
|
user.maxCharges,
|
||||||
|
user.currentCharges + (30 * (product.amount || 1))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "color":
|
||||||
|
if (product.variant && product.variant >= 32 && product.variant <= 63) {
|
||||||
|
const mask = 1 << (product.variant - 32);
|
||||||
|
updateData.extraColorsBitmap = user.extraColorsBitmap | mask;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "flag":
|
||||||
|
if (product.variant && product.variant >= 1 && product.variant <= 251) {
|
||||||
|
const flagsBitmap = user.flagsBitmap
|
||||||
|
? WplaceBitMap.fromBase64(Buffer.from(user.flagsBitmap)
|
||||||
|
.toString("base64"))
|
||||||
|
: new WplaceBitMap();
|
||||||
|
flagsBitmap.set(product.variant, true);
|
||||||
|
updateData.flagsBitmap = Buffer.from(flagsBitmap.toBase64(), "base64");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error purchasing item:", error);
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/flag/equip/:id", authMiddleware, async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const flagId = Number.parseInt(req.params["id"]);
|
||||||
|
|
||||||
|
if (Number.isNaN(flagId) || flagId < 1 || flagId > 251) {
|
||||||
|
return res.status(400)
|
||||||
|
.json({ error: "Bad Request", status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401)
|
||||||
|
.json({ error: "Unauthorized", status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagsBitmap = user.flagsBitmap
|
||||||
|
? WplaceBitMap.fromBase64(Buffer.from(user.flagsBitmap)
|
||||||
|
.toString("base64"))
|
||||||
|
: new WplaceBitMap();
|
||||||
|
|
||||||
|
if (!flagsBitmap.get(flagId)) {
|
||||||
|
return res.status(403)
|
||||||
|
.json({ error: "Forbidden", status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
data: { equippedFlag: flagId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error equipping flag:", error);
|
||||||
|
return res.status(500)
|
||||||
|
.json({ error: "Internal Server Error", status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface CreateAllianceInput {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAllianceDescriptionInput {
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAllianceHQInput {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGINATION_CONSTANTS = {
|
||||||
|
PAGE_SIZE: 50
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class AllianceService {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async getUserAlliance(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { alliance: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.alliance) {
|
||||||
|
throw new Error("No Alliance");
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCount = await this.prisma.user.count({
|
||||||
|
where: { allianceId: user.alliance.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.alliance.id,
|
||||||
|
name: user.alliance.name,
|
||||||
|
description: user.alliance.description || "",
|
||||||
|
hq: user.alliance.hqLatitude && user.alliance.hqLongitude
|
||||||
|
? {
|
||||||
|
latitude: user.alliance.hqLatitude,
|
||||||
|
longitude: user.alliance.hqLongitude
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
members: memberCount,
|
||||||
|
pixelsPainted: user.alliance.pixelsPainted || 0,
|
||||||
|
role: user.allianceRole,
|
||||||
|
createdAt: user.alliance.createdAt.toISOString(),
|
||||||
|
updatedAt: user.alliance.updatedAt.toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAlliance(userId: number, input: CreateAllianceInput) {
|
||||||
|
const { name } = input;
|
||||||
|
|
||||||
|
if (!name || typeof name !== "string") {
|
||||||
|
throw new Error("Alliance name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.allianceId) {
|
||||||
|
throw new Error("Already in alliance");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAlliance = await this.prisma.alliance.findUnique({
|
||||||
|
where: { name }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAlliance) {
|
||||||
|
throw new Error("Alliance name taken");
|
||||||
|
}
|
||||||
|
|
||||||
|
const alliance = await this.prisma.alliance.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
pixelsPainted: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
allianceId: alliance.id,
|
||||||
|
allianceRole: "admin"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: alliance.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDescription(userId: number, input: UpdateAllianceDescriptionInput) {
|
||||||
|
const { description } = input;
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.alliance.update({
|
||||||
|
where: { id: user.allianceId },
|
||||||
|
data: { description }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvites(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
let invite = await this.prisma.allianceInvite.findFirst({
|
||||||
|
where: { allianceId: user.allianceId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
invite = await this.prisma.allianceInvite.create({
|
||||||
|
data: { allianceId: user.allianceId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [invite.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinAlliance(userId: number, inviteId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inviteId) {
|
||||||
|
throw new Error("Invalid invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteRecord = await this.prisma.allianceInvite.findUnique({
|
||||||
|
where: { id: inviteId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inviteRecord) {
|
||||||
|
throw new Error("Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.allianceId === inviteRecord.allianceId) {
|
||||||
|
return { success: "true" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.allianceId) {
|
||||||
|
throw new Error("Already Reported");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannedUser = await this.prisma.bannedUser.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_allianceId: {
|
||||||
|
userId,
|
||||||
|
allianceId: inviteRecord.allianceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bannedUser) {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
allianceId: inviteRecord.allianceId,
|
||||||
|
allianceRole: "member"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: "true" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHeadquarters(userId: number, input: UpdateAllianceHQInput) {
|
||||||
|
const { latitude, longitude } = input;
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.alliance.update({
|
||||||
|
where: { id: user.allianceId },
|
||||||
|
data: {
|
||||||
|
hqLatitude: latitude,
|
||||||
|
hqLongitude: longitude
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMembers(userId: number, page: number) {
|
||||||
|
const pageSize = PAGINATION_CONSTANTS.PAGE_SIZE;
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await this.prisma.user.findMany({
|
||||||
|
where: { allianceId: user.allianceId },
|
||||||
|
skip: page * pageSize,
|
||||||
|
take: pageSize + 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
allianceRole: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNext = members.length > pageSize;
|
||||||
|
const data = hasNext ? members.slice(0, -1) : members;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.map(member => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
role: member.allianceRole
|
||||||
|
})),
|
||||||
|
hasNext
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBannedMembers(userId: number, page: number) {
|
||||||
|
const pageSize = PAGINATION_CONSTANTS.PAGE_SIZE;
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannedUsers = await this.prisma.bannedUser.findMany({
|
||||||
|
where: { allianceId: user.allianceId },
|
||||||
|
skip: page * pageSize,
|
||||||
|
take: pageSize + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNext = bannedUsers.length > pageSize;
|
||||||
|
const data = hasNext ? bannedUsers.slice(0, -1) : bannedUsers;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.map(banned => ({
|
||||||
|
id: banned.userId,
|
||||||
|
name: `User ${banned.userId}`
|
||||||
|
})),
|
||||||
|
hasNext
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async promoteUser(userId: number, promotedUserId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: promotedUserId,
|
||||||
|
allianceId: user.allianceId
|
||||||
|
},
|
||||||
|
data: { allianceRole: "admin" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async banUser(userId: number, bannedUserId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: bannedUserId },
|
||||||
|
data: { allianceId: null, allianceRole: "member" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.bannedUser.create({
|
||||||
|
data: {
|
||||||
|
userId: bannedUserId,
|
||||||
|
allianceId: user.allianceId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async unbanUser(userId: number, unbannedUserId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId || user.allianceRole !== "admin") {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.bannedUser.delete({
|
||||||
|
where: {
|
||||||
|
userId_allianceId: {
|
||||||
|
userId: unbannedUserId,
|
||||||
|
allianceId: user.allianceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeaderboard(userId: number, _mode: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.allianceId) {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await this.prisma.user.findMany({
|
||||||
|
where: { allianceId: user.allianceId },
|
||||||
|
orderBy: { pixelsPainted: "desc" },
|
||||||
|
take: 50,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
equippedFlag: true,
|
||||||
|
pixelsPainted: true,
|
||||||
|
showLastPixel: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return members.map(member => ({
|
||||||
|
userId: member.id,
|
||||||
|
name: member.name,
|
||||||
|
equippedFlag: member.equippedFlag,
|
||||||
|
pixelsPainted: member.pixelsPainted,
|
||||||
|
...(member.showLastPixel && {
|
||||||
|
lastLatitude: 22.527_739_206_672_393,
|
||||||
|
lastLongitude: 114.027_626_953_124_97
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { createCanvas } from "@napi-rs/canvas";
|
||||||
|
import { checkColorUnlocked, COLOR_PALETTE } from "../utils/colors.js";
|
||||||
|
import { calculateChargeRecharge } from "../utils/charges.js";
|
||||||
|
import { getRegionForCoordinates } from "../config/regions.js";
|
||||||
|
|
||||||
|
export interface PaintPixelsInput {
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
colors: number[];
|
||||||
|
coords: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaintPixelsResult {
|
||||||
|
painted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RandomTileResult {
|
||||||
|
pixel: { x: number; y: number };
|
||||||
|
tile: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelInfoResult {
|
||||||
|
paintedBy: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
allianceId: number;
|
||||||
|
allianceName: string;
|
||||||
|
equippedFlag: number;
|
||||||
|
};
|
||||||
|
region: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateLevel(pixelsPainted: number): number {
|
||||||
|
return Math.floor(Math.sqrt(pixelsPainted / 100)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PixelService {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async getRandomTile(): Promise<RandomTileResult> {
|
||||||
|
const recentThreshold = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
let recentPixelCount = await this.prisma.pixel.count({
|
||||||
|
where: {
|
||||||
|
paintedAt: {
|
||||||
|
gte: recentThreshold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let firstRecentPixel = await this.prisma.pixel.findFirst({
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
paintedAt: {
|
||||||
|
gte: recentThreshold
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: { paintedAt: "desc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentPixelCount === 0 || !firstRecentPixel) {
|
||||||
|
recentPixelCount = await this.prisma.pixel.count();
|
||||||
|
|
||||||
|
firstRecentPixel = await this.prisma.pixel.findFirst({
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
},
|
||||||
|
orderBy: { paintedAt: "desc" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = (firstRecentPixel?.id || 1) + Math.floor(Math.random() * recentPixelCount);
|
||||||
|
|
||||||
|
const randomPixel = await this.prisma.pixel.findFirst({
|
||||||
|
select: {
|
||||||
|
x: true,
|
||||||
|
y: true,
|
||||||
|
tileX: true,
|
||||||
|
tileY: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: { gte: id }
|
||||||
|
},
|
||||||
|
orderBy: { id: "asc" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!randomPixel) {
|
||||||
|
return {
|
||||||
|
pixel: { x: 500, y: 500 },
|
||||||
|
tile: { x: 1024, y: 1024 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pixel: { x: randomPixel.x, y: randomPixel.y },
|
||||||
|
tile: { x: randomPixel.tileX, y: randomPixel.tileY }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPixelInfo(tileX: number, tileY: number, x: number, y: number): Promise<PixelInfoResult> {
|
||||||
|
let paintedBy = {
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
allianceId: 0,
|
||||||
|
allianceName: "",
|
||||||
|
equippedFlag: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const pixel = await this.prisma.pixel.findUnique({
|
||||||
|
where: {
|
||||||
|
tileX_tileY_x_y: { tileX, tileY, x, y }
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: { alliance: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pixel) {
|
||||||
|
paintedBy = {
|
||||||
|
id: pixel.user.id,
|
||||||
|
name: pixel.user.name,
|
||||||
|
allianceId: pixel.user.allianceId || 0,
|
||||||
|
allianceName: pixel.user.alliance?.name || "",
|
||||||
|
equippedFlag: pixel.user.equippedFlag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = getRegionForCoordinates(tileX, tileY, x, y);
|
||||||
|
|
||||||
|
return { paintedBy, region };
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTileImage(tileX: number, tileY: number): Promise<Buffer> {
|
||||||
|
const canvas = createCanvas(1000, 1000);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
const pixels = await this.prisma.pixel.findMany({
|
||||||
|
where: { tileX, tileY }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pixel of pixels) {
|
||||||
|
const color = COLOR_PALETTE[pixel.colorId];
|
||||||
|
if (color && pixel.colorId !== 0) {
|
||||||
|
const [r, g, b] = color.rgb;
|
||||||
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
ctx.fillRect(pixel.x, pixel.y, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.toBuffer("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
async paintPixels(userId: number, input: PaintPixelsInput): Promise<PaintPixelsResult> {
|
||||||
|
const { tileX, tileY, colors, coords } = input;
|
||||||
|
|
||||||
|
if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) {
|
||||||
|
throw new Error("Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colors.length * 2 !== coords.length) {
|
||||||
|
throw new Error("Bad Request");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.banned || user.timeoutUntil > new Date()) {
|
||||||
|
throw new Error("banned");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCharges = calculateChargeRecharge(
|
||||||
|
user.currentCharges,
|
||||||
|
user.maxCharges,
|
||||||
|
user.chargesLastUpdatedAt || new Date(),
|
||||||
|
user.chargesCooldownMs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentCharges < colors.length) {
|
||||||
|
throw new Error("attempted to paint more pixels than there was charges.");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.currentCharges = currentCharges;
|
||||||
|
|
||||||
|
for (const colorId of colors) {
|
||||||
|
if (!checkColorUnlocked(colorId, user.extraColorsBitmap)) {
|
||||||
|
throw new Error("attempted to paint with a colour that was not purchased.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairedCoords = [];
|
||||||
|
for (let i = 0; i < coords.length; i += 2) {
|
||||||
|
pairedCoords.push({ x: coords[i], y: coords[i + 1] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const regionCache = new Map();
|
||||||
|
const validPixels = [];
|
||||||
|
|
||||||
|
for (const [i, colorId] of colors.entries()) {
|
||||||
|
const coord = pairedCoords[i];
|
||||||
|
if (!coord) continue;
|
||||||
|
const { x, y } = coord;
|
||||||
|
|
||||||
|
if (x === undefined || y === undefined || x < 0 || x >= 1000 || y < 0 || y >= 1000) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordKey = `${tileX},${tileY},${x},${y}`;
|
||||||
|
let region;
|
||||||
|
if (regionCache.has(coordKey)) {
|
||||||
|
region = regionCache.get(coordKey);
|
||||||
|
} else {
|
||||||
|
region = getRegionForCoordinates(tileX, tileY, x, y);
|
||||||
|
regionCache.set(coordKey, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
validPixels.push({
|
||||||
|
x, y, colorId, region
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const painted = validPixels.length;
|
||||||
|
if (painted === 0) {
|
||||||
|
return { painted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEquippedFlag = user.equippedFlag;
|
||||||
|
let totalChargeCost = 0;
|
||||||
|
let discountedPixels = 0;
|
||||||
|
|
||||||
|
for (const pixel of validPixels) {
|
||||||
|
if (userEquippedFlag && userEquippedFlag === pixel.region.flagId) {
|
||||||
|
totalChargeCost += 0.9;
|
||||||
|
discountedPixels++;
|
||||||
|
} else {
|
||||||
|
totalChargeCost += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discountedPixels > 0 && validPixels[0]) {
|
||||||
|
console.log(`Applied 10% flag discount to ${discountedPixels} pixels in ${validPixels[0].region.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.tile.upsert({
|
||||||
|
where: { x_y: { x: tileX, y: tileY } },
|
||||||
|
create: { x: tileX, y: tileY },
|
||||||
|
update: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (validPixels.length > 0) {
|
||||||
|
const values = validPixels.map(pixel =>
|
||||||
|
`(${tileX}, ${tileY}, ${pixel.x}, ${pixel.y}, ${pixel.colorId}, ${userId}, '${now.toISOString()
|
||||||
|
.slice(0, 19)
|
||||||
|
.replace("T", " ")}')`
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const bulkQuery = `
|
||||||
|
INSERT INTO Pixel (tileX, tileY, x, y, colorId, paintedBy, paintedAt)
|
||||||
|
VALUES ${values}
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
colorId = VALUES(colorId),
|
||||||
|
paintedBy = VALUES(paintedBy),
|
||||||
|
paintedAt = VALUES(paintedAt)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.prisma.$executeRawUnsafe(bulkQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCharges = Math.max(0, user.currentCharges - totalChargeCost);
|
||||||
|
const newPixelsPainted = user.pixelsPainted + painted;
|
||||||
|
const newLevel = calculateLevel(newPixelsPainted);
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
currentCharges: newCharges,
|
||||||
|
pixelsPainted: newPixelsPainted,
|
||||||
|
level: newLevel,
|
||||||
|
chargesLastUpdatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.allianceId) {
|
||||||
|
await this.prisma.alliance.update({
|
||||||
|
where: { id: user.allianceId },
|
||||||
|
data: {
|
||||||
|
pixelsPainted: { increment: painted }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { painted };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { calculateChargeRecharge } from "../utils/charges.js";
|
||||||
|
|
||||||
|
export interface UpdateUserInput {
|
||||||
|
name?: string;
|
||||||
|
showLastPixel?: boolean;
|
||||||
|
discord?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async getUserProfile(userId: number) {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
alliance: true,
|
||||||
|
favoriteLocations: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedCharges = calculateChargeRecharge(
|
||||||
|
user.currentCharges,
|
||||||
|
user.maxCharges,
|
||||||
|
user.chargesLastUpdatedAt,
|
||||||
|
user.chargesCooldownMs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedCharges !== user.currentCharges) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
currentCharges: updatedCharges,
|
||||||
|
chargesLastUpdatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
user.currentCharges = updatedCharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagsBitmap = user.flagsBitmap
|
||||||
|
? Buffer.from(user.flagsBitmap)
|
||||||
|
.toString("base64")
|
||||||
|
: "AA==";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
discord: user.discord || "",
|
||||||
|
country: user.country,
|
||||||
|
banned: user.banned,
|
||||||
|
charges: {
|
||||||
|
cooldownMs: user.chargesCooldownMs,
|
||||||
|
count: user.currentCharges,
|
||||||
|
max: user.maxCharges
|
||||||
|
},
|
||||||
|
droplets: user.droplets,
|
||||||
|
equippedFlag: user.equippedFlag,
|
||||||
|
extraColorsBitmap: user.extraColorsBitmap,
|
||||||
|
favoriteLocations: user.favoriteLocations.map(loc => ({
|
||||||
|
id: loc.id,
|
||||||
|
name: loc.name,
|
||||||
|
latitude: loc.latitude,
|
||||||
|
longitude: loc.longitude
|
||||||
|
})),
|
||||||
|
flagsBitmap,
|
||||||
|
role: user.role,
|
||||||
|
isCustomer: user.isCustomer,
|
||||||
|
level: user.level,
|
||||||
|
maxFavoriteLocations: user.maxFavoriteLocations,
|
||||||
|
needsPhoneVerification: user.needsPhoneVerification,
|
||||||
|
picture: user.picture || "",
|
||||||
|
pixelsPainted: user.pixelsPainted,
|
||||||
|
showLastPixel: user.showLastPixel,
|
||||||
|
timeoutUntil: user.timeoutUntil.toISOString(),
|
||||||
|
allianceId: user.allianceId,
|
||||||
|
allianceRole: user.allianceRole
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(userId: number, input: UpdateUserInput) {
|
||||||
|
const { name, showLastPixel, discord } = input;
|
||||||
|
|
||||||
|
if (name && name.length > 16) {
|
||||||
|
throw new Error("The name has more than 16 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (showLastPixel !== undefined) updateData.showLastPixel = showLastPixel;
|
||||||
|
if (discord !== undefined) updateData.discord = discord;
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfilePictures(userId: number) {
|
||||||
|
return await this.prisma.profilePicture.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Request } from "@tinyhttp/app";
|
||||||
|
|
||||||
|
export interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
file?: {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorPalette {
|
||||||
|
[key: number]: {
|
||||||
|
rgb: [number, number, number];
|
||||||
|
paid: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BitMap {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
set(index: number, value: boolean): void;
|
||||||
|
get(index: number): boolean;
|
||||||
|
toBase64(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
User = "user",
|
||||||
|
Admin = "admin",
|
||||||
|
Moderator = "moderator",
|
||||||
|
GlobalModerator = "global_moderator"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BanReason {
|
||||||
|
Doxxing = "doxxing",
|
||||||
|
Bot = "bot",
|
||||||
|
HateSpeech = "hate-speech",
|
||||||
|
MultiAccounting = "multi-accounting",
|
||||||
|
InappropriateContent = "inappropriate-content",
|
||||||
|
Other = "other"
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { BitMap } from "../types/index.js";
|
||||||
|
|
||||||
|
export class WplaceBitMap implements BitMap {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
|
||||||
|
constructor(bytes?: Uint8Array) {
|
||||||
|
this.bytes = bytes || new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(index: number, value: boolean): void {
|
||||||
|
const byteIndex = Math.floor(index / 8);
|
||||||
|
const bitIndex = index % 8;
|
||||||
|
|
||||||
|
if (byteIndex >= this.bytes.length) {
|
||||||
|
const newBytes = new Uint8Array(byteIndex + 1);
|
||||||
|
const offset = newBytes.length - this.bytes.length;
|
||||||
|
for (let i = 0; i < this.bytes.length; i++) {
|
||||||
|
newBytes[i + offset] = this.bytes[i]!;
|
||||||
|
}
|
||||||
|
this.bytes = newBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIndex = this.bytes.length - 1 - byteIndex;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
this.bytes[realIndex]! |= (1 << bitIndex);
|
||||||
|
} else {
|
||||||
|
this.bytes[realIndex]! &= ~(1 << bitIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(index: number): boolean {
|
||||||
|
const byteIndex = Math.floor(index / 8);
|
||||||
|
const bitIndex = index % 8;
|
||||||
|
|
||||||
|
if (byteIndex >= this.bytes.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIndex = this.bytes.length - 1 - byteIndex;
|
||||||
|
return (this.bytes[realIndex]! & (1 << bitIndex)) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
toBase64(): string {
|
||||||
|
return Buffer.from(this.bytes)
|
||||||
|
.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromBase64(base64: string): WplaceBitMap {
|
||||||
|
const bytes = new Uint8Array(Buffer.from(base64, "base64"));
|
||||||
|
return new WplaceBitMap(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function calculateChargeRecharge(currentCharges: number, maxCharges: number, lastUpdate: Date, cooldownMs: number): number {
|
||||||
|
if (currentCharges >= maxCharges) {
|
||||||
|
return currentCharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSinceLastUpdate = Date.now() - lastUpdate.getTime();
|
||||||
|
const chargesGenerated = Math.floor(timeSinceLastUpdate / cooldownMs);
|
||||||
|
|
||||||
|
return Math.min(maxCharges, currentCharges + chargesGenerated);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ColorPalette } from "../types/index.js";
|
||||||
|
|
||||||
|
export const COLOR_PALETTE: ColorPalette = {
|
||||||
|
0: { rgb: [0, 0, 0], paid: false }, // Transparent
|
||||||
|
1: { rgb: [0, 0, 0], paid: false },
|
||||||
|
2: { rgb: [60, 60, 60], paid: false },
|
||||||
|
3: { rgb: [120, 120, 120], paid: false },
|
||||||
|
4: { rgb: [210, 210, 210], paid: false },
|
||||||
|
5: { rgb: [255, 255, 255], paid: false },
|
||||||
|
6: { rgb: [96, 0, 24], paid: false },
|
||||||
|
7: { rgb: [237, 28, 36], paid: false },
|
||||||
|
8: { rgb: [255, 127, 39], paid: false },
|
||||||
|
9: { rgb: [246, 170, 9], paid: false },
|
||||||
|
10: { rgb: [249, 221, 59], paid: false },
|
||||||
|
11: { rgb: [255, 250, 188], paid: false },
|
||||||
|
12: { rgb: [14, 185, 104], paid: false },
|
||||||
|
13: { rgb: [19, 230, 123], paid: false },
|
||||||
|
14: { rgb: [135, 255, 94], paid: false },
|
||||||
|
15: { rgb: [12, 129, 110], paid: false },
|
||||||
|
16: { rgb: [16, 174, 166], paid: false },
|
||||||
|
17: { rgb: [19, 225, 190], paid: false },
|
||||||
|
18: { rgb: [40, 80, 158], paid: false },
|
||||||
|
19: { rgb: [64, 147, 228], paid: false },
|
||||||
|
20: { rgb: [96, 247, 242], paid: false },
|
||||||
|
21: { rgb: [107, 80, 246], paid: false },
|
||||||
|
22: { rgb: [153, 177, 251], paid: false },
|
||||||
|
23: { rgb: [120, 12, 153], paid: false },
|
||||||
|
24: { rgb: [170, 56, 185], paid: false },
|
||||||
|
25: { rgb: [224, 159, 249], paid: false },
|
||||||
|
26: { rgb: [203, 0, 122], paid: false },
|
||||||
|
27: { rgb: [236, 31, 128], paid: false },
|
||||||
|
28: { rgb: [243, 141, 169], paid: false },
|
||||||
|
29: { rgb: [104, 70, 52], paid: false },
|
||||||
|
30: { rgb: [149, 104, 42], paid: false },
|
||||||
|
31: { rgb: [248, 178, 119], paid: false },
|
||||||
|
32: { rgb: [170, 170, 170], paid: true },
|
||||||
|
33: { rgb: [165, 14, 30], paid: true },
|
||||||
|
34: { rgb: [250, 128, 114], paid: true },
|
||||||
|
35: { rgb: [228, 92, 26], paid: true },
|
||||||
|
36: { rgb: [214, 181, 148], paid: true },
|
||||||
|
37: { rgb: [156, 132, 49], paid: true },
|
||||||
|
38: { rgb: [197, 173, 49], paid: true },
|
||||||
|
39: { rgb: [232, 212, 95], paid: true },
|
||||||
|
40: { rgb: [74, 107, 58], paid: true },
|
||||||
|
41: { rgb: [90, 148, 74], paid: true },
|
||||||
|
42: { rgb: [132, 197, 115], paid: true },
|
||||||
|
43: { rgb: [15, 121, 159], paid: true },
|
||||||
|
44: { rgb: [187, 250, 242], paid: true },
|
||||||
|
45: { rgb: [125, 199, 255], paid: true },
|
||||||
|
46: { rgb: [77, 49, 184], paid: true },
|
||||||
|
47: { rgb: [74, 66, 132], paid: true },
|
||||||
|
48: { rgb: [122, 113, 196], paid: true },
|
||||||
|
49: { rgb: [181, 174, 241], paid: true },
|
||||||
|
50: { rgb: [219, 164, 99], paid: true },
|
||||||
|
51: { rgb: [209, 128, 81], paid: true },
|
||||||
|
52: { rgb: [255, 197, 165], paid: true },
|
||||||
|
53: { rgb: [155, 82, 73], paid: true },
|
||||||
|
54: { rgb: [209, 128, 120], paid: true },
|
||||||
|
55: { rgb: [250, 182, 164], paid: true },
|
||||||
|
56: { rgb: [123, 99, 82], paid: true },
|
||||||
|
57: { rgb: [156, 132, 107], paid: true },
|
||||||
|
58: { rgb: [51, 57, 65], paid: true },
|
||||||
|
59: { rgb: [109, 117, 141], paid: true },
|
||||||
|
60: { rgb: [179, 185, 209], paid: true },
|
||||||
|
61: { rgb: [109, 100, 63], paid: true },
|
||||||
|
62: { rgb: [148, 140, 107], paid: true },
|
||||||
|
63: { rgb: [205, 197, 158], paid: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkColorUnlocked = (colorId: number, extraColorsBitmap: number): boolean => {
|
||||||
|
if (colorId < 32) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mask = 1 << (colorId - 32);
|
||||||
|
return (extraColorsBitmap & mask) !== 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const calculateLevel = (totalPainted: number): number => {
|
||||||
|
const base = Math.pow(30, 0.65);
|
||||||
|
return Math.pow(totalPainted, 0.65) / base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateDropletsForLevel = (level: number): number => {
|
||||||
|
return Math.floor(level) * 500;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateMaxChargesForLevel = (level: number): number => {
|
||||||
|
return 20 + (Math.floor(level) * 2);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
export const HTTP_STATUS = {
|
||||||
|
OK: 200,
|
||||||
|
CREATED: 201,
|
||||||
|
NO_CONTENT: 204,
|
||||||
|
ALREADY_REPORTED: 208,
|
||||||
|
BAD_REQUEST: 400,
|
||||||
|
UNAUTHORIZED: 401,
|
||||||
|
FORBIDDEN: 403,
|
||||||
|
NOT_FOUND: 404,
|
||||||
|
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
|
||||||
|
INTERNAL_SERVER_ERROR: 500
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
BAD_REQUEST: "Bad Request",
|
||||||
|
UNAUTHORIZED: "Unauthorized",
|
||||||
|
FORBIDDEN: "Forbidden",
|
||||||
|
NOT_FOUND: "Not Found",
|
||||||
|
INTERNAL_SERVER_ERROR: "Internal Server Error",
|
||||||
|
USER_NOT_FOUND: "User not found",
|
||||||
|
NO_ALLIANCE: "No Alliance",
|
||||||
|
ALREADY_IN_ALLIANCE: "Already in alliance",
|
||||||
|
ALLIANCE_NAME_TAKEN: "Alliance name taken",
|
||||||
|
INVALID_INVITE: "Invalid invite",
|
||||||
|
ALREADY_REPORTED: "Already Reported",
|
||||||
|
REFRESH_TOKEN: "refresh"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function createErrorResponse(message: string, status: number) {
|
||||||
|
return { error: message, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSuccessResponse(data?: any) {
|
||||||
|
if (data === undefined) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function validateSeason(season: string): boolean {
|
||||||
|
return season === "s0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTileCoordinates(tileX: number, tileY: number): boolean {
|
||||||
|
return Number.isInteger(tileX) && Number.isInteger(tileY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePixelCoordinates(x: number, y: number): boolean {
|
||||||
|
return Number.isInteger(x) && Number.isInteger(y) &&
|
||||||
|
x >= 0 && x < 1000 &&
|
||||||
|
y >= 0 && y < 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePaginationPage(page: number): boolean {
|
||||||
|
return Number.isInteger(page) && page >= 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { validatePixelCoordinates, validateSeason, validateTileCoordinates } from "./common.js";
|
||||||
|
|
||||||
|
export interface PaintPixelsValidationInput {
|
||||||
|
season: string;
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
colors: any;
|
||||||
|
coords: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePaintPixels(input: PaintPixelsValidationInput): string | null {
|
||||||
|
const { season, tileX, tileY, colors, coords } = input;
|
||||||
|
|
||||||
|
if (!validateSeason(season)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateTileCoordinates(tileX, tileY)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colors.length * 2 !== coords.length) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelInfoValidationInput {
|
||||||
|
season: string;
|
||||||
|
tileX: number;
|
||||||
|
tileY: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePixelInfo(input: PixelInfoValidationInput): string | null {
|
||||||
|
const { season, tileX, tileY, x, y } = input;
|
||||||
|
|
||||||
|
if (!validateSeason(season)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateTileCoordinates(tileX, tileY)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validatePixelCoordinates(x, y)) {
|
||||||
|
return "Bad Request";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface UpdateUserValidationInput {
|
||||||
|
name?: string;
|
||||||
|
showLastPixel?: boolean;
|
||||||
|
discord?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUpdateUser(input: UpdateUserValidationInput): string | null {
|
||||||
|
const { name } = input;
|
||||||
|
|
||||||
|
if (name && name.length > 16) {
|
||||||
|
return "The name has more than 16 characters";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@tsconfig/node24", "@tsconfig/strictest"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
setupFiles: ["./tests/setup.ts"]
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user