Compare commits
50 Commits
release-55
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9285d952a5 | ||
|
|
fd5c7ab475 | ||
|
|
9a78a9b377 | ||
|
|
9e0193fc3c | ||
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df | ||
|
|
86b1afb2c7 | ||
|
|
99bc1f4e0f | ||
|
|
3166349dfb | ||
|
|
5a54352d95 | ||
|
|
a545726abf | ||
|
|
01488b8b3b | ||
|
|
b456af9448 | ||
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 | ||
|
|
6a9f5f5e99 | ||
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 | ||
|
|
cf5f3b3228 | ||
|
|
5a41816ac8 | ||
|
|
bf30a6cb2b | ||
|
|
4ca9b9a5c2 | ||
|
|
03abdef4f1 | ||
|
|
d9880fd83d | ||
|
|
39afcad089 | ||
|
|
99cb730bc0 | ||
|
|
d43402e162 | ||
|
|
9bb316b85f | ||
|
|
4aea68ee58 | ||
|
|
2f88c43236 | ||
|
|
ad3c561419 | ||
|
|
812937d2c7 | ||
|
|
459d7c91f7 | ||
|
|
b8b2dee4a4 | ||
|
|
eb0af871e1 | ||
|
|
3ccd7fd395 | ||
|
|
87f0f36aa1 | ||
|
|
8b90b3eef6 | ||
|
|
2489e94b0e | ||
|
|
144af17a70 | ||
|
|
02e52d7351 |
49
.env.example
@@ -0,0 +1,49 @@
|
|||||||
|
# Database
|
||||||
|
POSTGRES_USER=your_db_user
|
||||||
|
POSTGRES_PASSWORD=your_db_password
|
||||||
|
POSTGRES_DATABASE=your_db_name
|
||||||
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
|
||||||
|
DATABASE_UI_URL="https://db.example.com"
|
||||||
|
|
||||||
|
# Generic Config
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
SITE_URL="https://your-site.example.com"
|
||||||
|
SOURCE_CODE_URL="https://your-source-code.example.com"
|
||||||
|
TIME_TRAP_SECRET=your_time_trap_secret
|
||||||
|
LOGS_UI_URL="https://logs.example.com"
|
||||||
|
|
||||||
|
# Release Info
|
||||||
|
RELEASE_NUMBER=
|
||||||
|
RELEASE_DATE=
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL="redis://redis:6379"
|
||||||
|
|
||||||
|
# Crawl4AI
|
||||||
|
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
|
||||||
|
CRAWL4AI_API_TOKEN=your_crawl4ai_token
|
||||||
|
|
||||||
|
# Tor and I2P
|
||||||
|
ONION_ADDRESS="http://youronionaddress.onion"
|
||||||
|
I2P_ADDRESS="http://youri2paddress.b32.i2p"
|
||||||
|
I2P_PASS=your_i2p_password
|
||||||
|
|
||||||
|
# Push Notifications
|
||||||
|
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||||
|
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||||
|
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
|
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||||
|
OPENAI_MODEL=your_openai_model
|
||||||
|
OPENAI_RETRY=3
|
||||||
|
|
||||||
|
# Task schedules ---------------------------------------------------
|
||||||
|
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||||
|
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||||
|
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||||
|
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||||
|
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||||
|
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||||
|
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||||
|
|||||||
3
.vscode/extensions.json
vendored
@@ -7,7 +7,8 @@
|
|||||||
"golang.go",
|
"golang.go",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
"craigrbroughton.htmx-attributes",
|
"craigrbroughton.htmx-attributes",
|
||||||
"nefrob.vscode-just-syntax"
|
"nefrob.vscode-just-syntax",
|
||||||
|
"prisma.prisma"
|
||||||
],
|
],
|
||||||
"unwantedRecommendations": []
|
"unwantedRecommendations": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- database:/var/lib/postgresql/data:z
|
- database:/var/lib/postgresql/data:z
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
env_file:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
- .env
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD-SHELL',
|
||||||
|
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -20,19 +22,17 @@ services:
|
|||||||
pyworker:
|
pyworker:
|
||||||
build:
|
build:
|
||||||
context: ./pyworker
|
context: ./pyworker
|
||||||
|
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
env_file:
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
- .env
|
||||||
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
|
|
||||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
|
|
||||||
|
|
||||||
crawl4ai:
|
crawl4ai:
|
||||||
image: unclecode/crawl4ai:basic-amd64
|
image: unclecode/crawl4ai:basic-amd64
|
||||||
expose:
|
expose:
|
||||||
- "11235"
|
- '11235'
|
||||||
environment:
|
env_file:
|
||||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
|
- .env
|
||||||
MAX_CONCURRENT_TASKS: 10
|
|
||||||
volumes:
|
volumes:
|
||||||
- /dev/shm:/dev/shm
|
- /dev/shm:/dev/shm
|
||||||
deploy:
|
deploy:
|
||||||
@@ -46,22 +46,16 @@ services:
|
|||||||
image: redis:latest
|
image: redis:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
astro:
|
astro:
|
||||||
build:
|
build:
|
||||||
context: ./web
|
dockerfile: web/Dockerfile
|
||||||
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
|
||||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
|
||||||
REDIS_URL: "redis://redis:6379"
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -72,7 +66,15 @@ services:
|
|||||||
expose:
|
expose:
|
||||||
- 4321
|
- 4321
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'curl',
|
||||||
|
'-k',
|
||||||
|
'--silent',
|
||||||
|
'--fail',
|
||||||
|
'http://localhost:4321/internal-api/healthcheck',
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
|
|||||||
OPENAI_MODEL="xxxxxxxxx"
|
OPENAI_MODEL="xxxxxxxxx"
|
||||||
OPENAI_RETRY=3
|
OPENAI_RETRY=3
|
||||||
|
|
||||||
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month
|
# Task schedules ---------------------------------------------------
|
||||||
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day
|
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||||
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour
|
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||||
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day
|
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||||
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes
|
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||||
|
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||||
|
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||||
|
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||||
@@ -38,6 +38,7 @@ Required environment variables:
|
|||||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
||||||
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
||||||
|
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
|||||||
|
|
||||||
# Run service score recalculation task
|
# Run service score recalculation task
|
||||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||||
|
|
||||||
|
# Run inactive users cleanup task
|
||||||
|
uv run -m pyworker inactive-users
|
||||||
```
|
```
|
||||||
|
|
||||||
### Worker Mode
|
### Worker Mode
|
||||||
@@ -96,7 +100,7 @@ Tasks will run according to their configured cron schedules.
|
|||||||
### Force Triggers Task
|
### Force Triggers Task
|
||||||
|
|
||||||
- Maintains database triggers by forcing them to run under certain conditions
|
- Maintains database triggers by forcing them to run under certain conditions
|
||||||
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
|
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
|
||||||
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||||
|
|
||||||
### Service Score Recalculation Task
|
### Service Score Recalculation Task
|
||||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
|||||||
- Calculates privacy, trust, and overall scores
|
- Calculates privacy, trust, and overall scores
|
||||||
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
||||||
|
|
||||||
|
### Inactive Users Task
|
||||||
|
|
||||||
|
- Handles cleanup of inactive user accounts
|
||||||
|
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
|
||||||
|
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
|
||||||
|
- Deletes accounts that remain inactive after the warning period
|
||||||
|
- Cancels deletion for users who become active again
|
||||||
|
- Scheduled via `CRON_INACTIVE_USERS_TASK`
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
@@ -124,6 +137,7 @@ pyworker/
|
|||||||
│ │ ├── base.py
|
│ │ ├── base.py
|
||||||
│ │ ├── comment_moderation.py
|
│ │ ├── comment_moderation.py
|
||||||
│ │ ├── force_triggers.py
|
│ │ ├── force_triggers.py
|
||||||
|
│ │ ├── inactive_users.py
|
||||||
│ │ ├── service_score_recalc.py
|
│ │ ├── service_score_recalc.py
|
||||||
│ │ ├── tos_review.py
|
│ │ ├── tos_review.py
|
||||||
│ │ └── user_sentiment.py
|
│ │ └── user_sentiment.py
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
|
|||||||
from .tasks import (
|
from .tasks import (
|
||||||
CommentModerationTask,
|
CommentModerationTask,
|
||||||
ForceTriggersTask,
|
ForceTriggersTask,
|
||||||
|
InactiveUsersTask,
|
||||||
ServiceScoreRecalculationTask,
|
ServiceScoreRecalculationTask,
|
||||||
TosReviewTask,
|
TosReviewTask,
|
||||||
UserSentimentTask,
|
UserSentimentTask,
|
||||||
@@ -89,6 +90,23 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
|||||||
score_recalc_parser.add_argument(
|
score_recalc_parser.add_argument(
|
||||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
"--service-id", type=int, help="Specific service ID to process (optional)"
|
||||||
)
|
)
|
||||||
|
score_recalc_parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Recalculate scores for all services (ignores --service-id)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Service Score Recalculation task for all services
|
||||||
|
subparsers.add_parser(
|
||||||
|
"service-score-recalc-all",
|
||||||
|
help="Recalculate service scores for all services",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inactive users task
|
||||||
|
subparsers.add_parser(
|
||||||
|
"inactive-users",
|
||||||
|
help="Handle inactive users - send deletion warnings and clean up accounts",
|
||||||
|
)
|
||||||
|
|
||||||
return parser.parse_args(args)
|
return parser.parse_args(args)
|
||||||
|
|
||||||
@@ -295,12 +313,15 @@ def run_force_triggers_task() -> int:
|
|||||||
close_db_pool()
|
close_db_pool()
|
||||||
|
|
||||||
|
|
||||||
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
def run_service_score_recalc_task(
|
||||||
|
service_id: Optional[int] = None, all_services: bool = False
|
||||||
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Run the service score recalculation task.
|
Run the service score recalculation task.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_id: Optional specific service ID to process.
|
service_id: Optional specific service ID to process.
|
||||||
|
all_services: Whether to recalculate scores for all services.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exit code.
|
Exit code.
|
||||||
@@ -310,7 +331,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
|||||||
try:
|
try:
|
||||||
# Initialize task and use as context manager
|
# Initialize task and use as context manager
|
||||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
with ServiceScoreRecalculationTask() as task: # type: ignore
|
||||||
result = task.run(service_id) # type: ignore
|
if all_services:
|
||||||
|
queued = task.recalculate_all_services() # type: ignore
|
||||||
|
if not queued:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to queue recalculation jobs for all services"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Continuously process queued jobs in batches until none remain
|
||||||
|
while True:
|
||||||
|
_ = task.run() # type: ignore
|
||||||
|
|
||||||
|
# Check if there are still unprocessed jobs
|
||||||
|
remaining = 0
|
||||||
|
if task.conn:
|
||||||
|
with task.conn.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
|
||||||
|
)
|
||||||
|
remaining = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if remaining == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
result = True # All jobs processed successfully
|
||||||
|
|
||||||
|
else:
|
||||||
|
result = task.run(service_id) # type: ignore
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
logger.info("Successfully recalculated service scores")
|
logger.info("Successfully recalculated service scores")
|
||||||
else:
|
else:
|
||||||
@@ -323,6 +371,37 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
|||||||
close_db_pool()
|
close_db_pool()
|
||||||
|
|
||||||
|
|
||||||
|
def run_service_score_recalc_all_task() -> int:
|
||||||
|
"""
|
||||||
|
Run the service score recalculation task for all services.
|
||||||
|
"""
|
||||||
|
return run_service_score_recalc_task(all_services=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run_inactive_users_task() -> int:
|
||||||
|
"""
|
||||||
|
Run the inactive users task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Exit code.
|
||||||
|
"""
|
||||||
|
logger.info("Starting inactive users task")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Initialize task and use as context manager
|
||||||
|
with InactiveUsersTask() as task: # type: ignore
|
||||||
|
result = task.run() # type: ignore
|
||||||
|
logger.info(f"Inactive users task completed. Results: {result}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error running inactive users task: {e}")
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
# Ensure connection pool is closed even if an error occurs
|
||||||
|
close_db_pool()
|
||||||
|
|
||||||
|
|
||||||
def run_worker_mode() -> int:
|
def run_worker_mode() -> int:
|
||||||
"""
|
"""
|
||||||
Run in worker mode, scheduling tasks to run periodically.
|
Run in worker mode, scheduling tasks to run periodically.
|
||||||
@@ -334,42 +413,37 @@ def run_worker_mode() -> int:
|
|||||||
|
|
||||||
# Get task schedules from config
|
# Get task schedules from config
|
||||||
task_schedules = config.task_schedules
|
task_schedules = config.task_schedules
|
||||||
if not task_schedules:
|
logger.info(
|
||||||
|
"Found %s cron schedule%s from environment variables: %s",
|
||||||
|
len(task_schedules),
|
||||||
|
"s" if len(task_schedules) != 1 else "",
|
||||||
|
", ".join(task_schedules.keys()) if task_schedules else "<none>",
|
||||||
|
)
|
||||||
|
|
||||||
|
required_tasks: dict[str, Any] = {
|
||||||
|
"tosreview": run_tos_task,
|
||||||
|
"user_sentiment": run_sentiment_task,
|
||||||
|
"comment_moderation": run_moderation_task,
|
||||||
|
"force_triggers": run_force_triggers_task,
|
||||||
|
"inactive_users": run_inactive_users_task,
|
||||||
|
"service_score_recalc": run_service_score_recalc_task,
|
||||||
|
"service_score_recalc_all": run_service_score_recalc_all_task,
|
||||||
|
}
|
||||||
|
|
||||||
|
missing_tasks = [t for t in required_tasks if t not in task_schedules]
|
||||||
|
if missing_tasks:
|
||||||
logger.error(
|
logger.error(
|
||||||
"No task schedules defined. Set CRON_TASKNAME_TASK environment variables."
|
"Missing cron schedule for task%s: %s. Set the corresponding CRON_<TASKNAME>_TASK environment variable%s.",
|
||||||
|
"s" if len(missing_tasks) != 1 else "",
|
||||||
|
", ".join(missing_tasks),
|
||||||
|
"s" if len(missing_tasks) != 1 else "",
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize the scheduler
|
|
||||||
scheduler = TaskScheduler()
|
scheduler = TaskScheduler()
|
||||||
|
|
||||||
# Register tasks with their schedules
|
for task_name, task_callable in required_tasks.items():
|
||||||
for task_name, cron_expression in task_schedules.items():
|
scheduler.register_task(task_name, task_schedules[task_name], task_callable)
|
||||||
if task_name.lower() == "tosreview":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_tos_task)
|
|
||||||
elif task_name.lower() == "user_sentiment":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_sentiment_task)
|
|
||||||
elif task_name.lower() == "comment_moderation":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_moderation_task)
|
|
||||||
elif task_name.lower() == "force_triggers":
|
|
||||||
scheduler.register_task(task_name, cron_expression, run_force_triggers_task)
|
|
||||||
elif task_name.lower() == "service_score_recalc":
|
|
||||||
scheduler.register_task(
|
|
||||||
task_name, cron_expression, run_service_score_recalc_task
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Unknown task '{task_name}', skipping")
|
|
||||||
|
|
||||||
# Register service score recalculation task (every 5 minutes)
|
|
||||||
scheduler.register_task(
|
|
||||||
"service-score-recalc",
|
|
||||||
"*/5 * * * *",
|
|
||||||
run_service_score_recalc_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the scheduler if tasks were registered
|
# Start the scheduler if tasks were registered
|
||||||
if scheduler.tasks:
|
if scheduler.tasks:
|
||||||
@@ -419,7 +493,13 @@ def main() -> int:
|
|||||||
elif args.task == "force-triggers":
|
elif args.task == "force-triggers":
|
||||||
return run_force_triggers_task()
|
return run_force_triggers_task()
|
||||||
elif args.task == "service-score-recalc":
|
elif args.task == "service-score-recalc":
|
||||||
return run_service_score_recalc_task(args.service_id)
|
return run_service_score_recalc_task(
|
||||||
|
args.service_id, getattr(args, "all", False)
|
||||||
|
)
|
||||||
|
elif args.task == "service-score-recalc-all":
|
||||||
|
return run_service_score_recalc_all_task()
|
||||||
|
elif args.task == "inactive-users":
|
||||||
|
return run_inactive_users_task()
|
||||||
elif args.task:
|
elif args.task:
|
||||||
logger.error(f"Unknown task: {args.task}")
|
logger.error(f"Unknown task: {args.task}")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
|||||||
return remove_service_attribute(service_id, attribute_id)
|
return remove_service_attribute(service_id, attribute_id)
|
||||||
|
|
||||||
|
|
||||||
def save_tos_review(service_id: int, review: TosReviewType):
|
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||||
"""
|
"""Persist a TOS review and/or update the timestamp for a service.
|
||||||
Save a TOS review for a specific service.
|
|
||||||
|
|
||||||
Args:
|
If *review* is ``None`` the existing review (if any) is preserved while
|
||||||
service_id: The ID of the service.
|
only the ``tosReviewAt`` column is updated. This ensures we still track
|
||||||
review: A TypedDict containing the review data.
|
when the review task last ran even if the review generation failed or
|
||||||
|
produced no changes.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Serialize the dictionary to a JSON string for the database
|
|
||||||
review_json = json.dumps(review)
|
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
with conn.cursor(row_factory=dict_row) as cursor:
|
with conn.cursor(row_factory=dict_row) as cursor:
|
||||||
cursor.execute(
|
if review is None:
|
||||||
"""
|
cursor.execute(
|
||||||
UPDATE "Service"
|
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
|
||||||
SET "tosReview" = %s, "tosReviewAt" = NOW()
|
(service_id,),
|
||||||
WHERE id = %s
|
)
|
||||||
""",
|
else:
|
||||||
(review_json, service_id),
|
review_json = json.dumps(review)
|
||||||
)
|
cursor.execute(
|
||||||
|
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
|
||||||
|
(review_json, service_id),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(f"Successfully saved TOS review for service {service_id}")
|
logger.info(
|
||||||
|
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
|
|||||||
from .tasks import (
|
from .tasks import (
|
||||||
CommentModerationTask,
|
CommentModerationTask,
|
||||||
ForceTriggersTask,
|
ForceTriggersTask,
|
||||||
|
InactiveUsersTask,
|
||||||
ServiceScoreRecalculationTask,
|
ServiceScoreRecalculationTask,
|
||||||
TosReviewTask,
|
TosReviewTask,
|
||||||
UserSentimentTask,
|
UserSentimentTask,
|
||||||
@@ -62,25 +63,29 @@ class TaskScheduler:
|
|||||||
cron_expression: Cron expression defining the schedule.
|
cron_expression: Cron expression defining the schedule.
|
||||||
task_func: Function to execute.
|
task_func: Function to execute.
|
||||||
*args: Arguments to pass to the task function.
|
*args: Arguments to pass to the task function.
|
||||||
**kwargs: Keyword arguments to pass to the task function.
|
**kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
|
||||||
"""
|
"""
|
||||||
|
instantiate = kwargs.pop("instantiate", True)
|
||||||
# Declare task_instance variable with type annotation upfront
|
# Declare task_instance variable with type annotation upfront
|
||||||
task_instance: Any = None
|
task_instance: Any = None
|
||||||
|
|
||||||
# Initialize the appropriate task class based on the task name
|
if instantiate:
|
||||||
if task_name.lower() == "tosreview":
|
# Initialize the appropriate task class based on the task name
|
||||||
task_instance = TosReviewTask()
|
if task_name.lower() == "tosreview":
|
||||||
elif task_name.lower() == "user_sentiment":
|
task_instance = TosReviewTask()
|
||||||
task_instance = UserSentimentTask()
|
elif task_name.lower() == "user_sentiment":
|
||||||
elif task_name.lower() == "comment_moderation":
|
task_instance = UserSentimentTask()
|
||||||
task_instance = CommentModerationTask()
|
elif task_name.lower() == "comment_moderation":
|
||||||
elif task_name.lower() == "force_triggers":
|
task_instance = CommentModerationTask()
|
||||||
task_instance = ForceTriggersTask()
|
elif task_name.lower() == "force_triggers":
|
||||||
elif task_name.lower() == "service_score_recalc":
|
task_instance = ForceTriggersTask()
|
||||||
task_instance = ServiceScoreRecalculationTask()
|
elif task_name.lower() == "service_score_recalc":
|
||||||
else:
|
task_instance = ServiceScoreRecalculationTask()
|
||||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
elif task_name.lower() == "inactive_users":
|
||||||
return
|
task_instance = InactiveUsersTask()
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||||
|
return
|
||||||
|
|
||||||
self.tasks[task_name] = {
|
self.tasks[task_name] = {
|
||||||
"cron": cron_expression,
|
"cron": cron_expression,
|
||||||
@@ -126,8 +131,12 @@ class TaskScheduler:
|
|||||||
self.logger.info(f"Running task '{task_name}'")
|
self.logger.info(f"Running task '{task_name}'")
|
||||||
# Use task instance as a context manager to ensure
|
# Use task instance as a context manager to ensure
|
||||||
# a single database connection is used for the entire task
|
# a single database connection is used for the entire task
|
||||||
with task_info["instance"]:
|
if task_info["instance"]:
|
||||||
# Execute the registered task function with its arguments
|
with task_info["instance"]:
|
||||||
|
# Execute the registered task function with its arguments
|
||||||
|
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||||
|
else:
|
||||||
|
# Execute the registered task function without a context manager
|
||||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||||
self.logger.info(f"Task '{task_name}' completed")
|
self.logger.info(f"Task '{task_name}' completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from .base import Task
|
from .base import Task
|
||||||
from .comment_moderation import CommentModerationTask
|
from .comment_moderation import CommentModerationTask
|
||||||
from .force_triggers import ForceTriggersTask
|
from .force_triggers import ForceTriggersTask
|
||||||
|
from .inactive_users import InactiveUsersTask
|
||||||
from .service_score_recalc import ServiceScoreRecalculationTask
|
from .service_score_recalc import ServiceScoreRecalculationTask
|
||||||
from .tos_review import TosReviewTask
|
from .tos_review import TosReviewTask
|
||||||
from .user_sentiment import UserSentimentTask
|
from .user_sentiment import UserSentimentTask
|
||||||
@@ -11,6 +12,7 @@ __all__ = [
|
|||||||
"Task",
|
"Task",
|
||||||
"CommentModerationTask",
|
"CommentModerationTask",
|
||||||
"ForceTriggersTask",
|
"ForceTriggersTask",
|
||||||
|
"InactiveUsersTask",
|
||||||
"ServiceScoreRecalculationTask",
|
"ServiceScoreRecalculationTask",
|
||||||
"TosReviewTask",
|
"TosReviewTask",
|
||||||
"UserSentimentTask",
|
"UserSentimentTask",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
|||||||
Force triggers to run under certain conditions.
|
Force triggers to run under certain conditions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("force_triggers")
|
super().__init__("force_triggers")
|
||||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
|||||||
|
|
||||||
update_query = f"""
|
update_query = f"""
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||||
WHERE "isRecentlyListed" = TRUE
|
WHERE "isRecentlyApproved" = TRUE
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with self.conn.cursor() as cursor:
|
with self.conn.cursor() as cursor:
|
||||||
|
|||||||
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from pyworker.database import execute_db_command, run_db_query
|
||||||
|
from pyworker.tasks.base import Task
|
||||||
|
|
||||||
|
|
||||||
|
class InactiveUsersTask(Task):
|
||||||
|
"""Task for handling inactive users"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the inactive users task."""
|
||||||
|
super().__init__("inactive_users")
|
||||||
|
|
||||||
|
def run(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Run the inactive users task.
|
||||||
|
|
||||||
|
This task:
|
||||||
|
1. Identifies users who have been inactive for 1 year
|
||||||
|
2. Schedules them for deletion
|
||||||
|
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
|
||||||
|
4. Deletes accounts that have reached their deletion date
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
"users_scheduled_for_deletion": 0,
|
||||||
|
"notifications_sent": 0,
|
||||||
|
"accounts_deleted": 0,
|
||||||
|
"deletions_cancelled": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Cancel deletion for users who became active again
|
||||||
|
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
|
||||||
|
|
||||||
|
# Step 2: Schedule new inactive users for deletion
|
||||||
|
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
|
||||||
|
|
||||||
|
# Step 3: Send warning notifications
|
||||||
|
results["notifications_sent"] = self._send_deletion_warnings()
|
||||||
|
|
||||||
|
# Step 4: Delete accounts that have reached their deletion date
|
||||||
|
results["accounts_deleted"] = self._delete_scheduled_accounts()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Inactive users task completed. "
|
||||||
|
f"Deletions cancelled: {results['deletions_cancelled']}, "
|
||||||
|
f"Scheduled: {results['users_scheduled_for_deletion']}, "
|
||||||
|
f"Notifications sent: {results['notifications_sent']}, "
|
||||||
|
f"Accounts deleted: {results['accounts_deleted']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _schedule_inactive_users_for_deletion(self) -> int:
|
||||||
|
"""
|
||||||
|
Schedule inactive users for deletion.
|
||||||
|
|
||||||
|
A user is considered inactive if:
|
||||||
|
- Account was created more than 1 year ago
|
||||||
|
- Has 0 karma
|
||||||
|
- Has no comments, comment votes, or service suggestions
|
||||||
|
- Is not scheduled for deletion already
|
||||||
|
- Is not an admin or moderator
|
||||||
|
"""
|
||||||
|
one_year_ago = datetime.now() - timedelta(days=365)
|
||||||
|
deletion_date = date.today() + timedelta(days=30) # 30 days from today
|
||||||
|
|
||||||
|
# Find inactive users
|
||||||
|
query = """
|
||||||
|
UPDATE "User"
|
||||||
|
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
|
||||||
|
WHERE "id" IN (
|
||||||
|
SELECT u."id"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."createdAt" < %s
|
||||||
|
AND u."scheduledDeletionAt" IS NULL
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
count = execute_db_command(query, (deletion_date, one_year_ago))
|
||||||
|
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _send_deletion_warnings(self) -> int:
|
||||||
|
"""
|
||||||
|
Send deletion warning notifications to users at appropriate intervals.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
notifications_sent = 0
|
||||||
|
|
||||||
|
# Define warning intervals and their corresponding notification types
|
||||||
|
warning_intervals = [
|
||||||
|
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
|
||||||
|
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
|
||||||
|
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
|
||||||
|
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for days_before, notification_type in warning_intervals:
|
||||||
|
# Find users who should receive this warning (exact date match)
|
||||||
|
target_date = today + timedelta(days=days_before)
|
||||||
|
|
||||||
|
# Check if user is still inactive (no recent activity)
|
||||||
|
users_query = """
|
||||||
|
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."scheduledDeletionAt" = %s
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Notification" n
|
||||||
|
WHERE n."userId" = u."id"
|
||||||
|
AND n."type" = %s
|
||||||
|
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
-- Still check if user is inactive (no activity since being scheduled)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = u."id"
|
||||||
|
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = u."id"
|
||||||
|
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = u."id"
|
||||||
|
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
users = run_db_query(users_query, (target_date, notification_type))
|
||||||
|
|
||||||
|
# Create notifications for these users
|
||||||
|
for user in users:
|
||||||
|
insert_notification_query = """
|
||||||
|
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
|
||||||
|
VALUES (%s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_db_command(insert_notification_query, (user['id'], notification_type))
|
||||||
|
notifications_sent += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Sent {notification_type} notification to user {user['name']} "
|
||||||
|
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return notifications_sent
|
||||||
|
|
||||||
|
def _delete_scheduled_accounts(self) -> int:
|
||||||
|
"""
|
||||||
|
Delete accounts that have reached their scheduled deletion date and are still inactive.
|
||||||
|
"""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Find users scheduled for deletion who are still inactive
|
||||||
|
users_to_delete_query = """
|
||||||
|
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."scheduledDeletionAt" <= %s
|
||||||
|
AND u."admin" = false
|
||||||
|
AND u."moderator" = false
|
||||||
|
AND u."totalKarma" = 0
|
||||||
|
-- Double-check they're still inactive
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = u."id"
|
||||||
|
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = u."id"
|
||||||
|
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = u."id"
|
||||||
|
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
users_to_delete = run_db_query(users_to_delete_query, (today,))
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
for user in users_to_delete:
|
||||||
|
try:
|
||||||
|
# Delete the user (this will cascade and delete related records)
|
||||||
|
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
|
||||||
|
execute_db_command(delete_query, (user['id'],))
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
|
||||||
|
f"scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
def _cancel_deletion_for_active_users(self) -> int:
|
||||||
|
"""
|
||||||
|
Cancel scheduled deletion for users who have become active again.
|
||||||
|
"""
|
||||||
|
# Find users scheduled for deletion who have recent activity or gained karma
|
||||||
|
query = """
|
||||||
|
UPDATE "User"
|
||||||
|
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
|
||||||
|
WHERE "scheduledDeletionAt" IS NOT NULL
|
||||||
|
AND (
|
||||||
|
"totalKarma" > 0
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "Comment" c
|
||||||
|
WHERE c."authorId" = "User"."id"
|
||||||
|
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "CommentVote" cv
|
||||||
|
WHERE cv."userId" = "User"."id"
|
||||||
|
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM "ServiceSuggestion" ss
|
||||||
|
WHERE ss."userId" = "User"."id"
|
||||||
|
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
count = execute_db_command(query)
|
||||||
|
if count > 0:
|
||||||
|
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "isActive" = TRUE
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
services = cursor.fetchall()
|
services = cursor.fetchall()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
|
|||||||
import hashlib
|
import hashlib
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
||||||
from pyworker.tasks.base import Task
|
from pyworker.tasks.base import Task
|
||||||
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
|
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
|
||||||
@@ -32,8 +34,8 @@ class TosReviewTask(Task):
|
|||||||
service_name = service["name"]
|
service_name = service["name"]
|
||||||
verification_status = service.get("verificationStatus")
|
verification_status = service.get("verificationStatus")
|
||||||
|
|
||||||
# Only process verified or approved services
|
# Only process verified, approved, or community contributed services
|
||||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||||
)
|
)
|
||||||
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
|
|||||||
)
|
)
|
||||||
self.logger.info(f"TOS URLs: {tos_urls}")
|
self.logger.info(f"TOS URLs: {tos_urls}")
|
||||||
|
|
||||||
|
review = self.get_tos_review(tos_urls, service.get("tosReview"))
|
||||||
|
|
||||||
|
# Always update the processed timestamp, even if review is None
|
||||||
|
save_tos_review(service_id, review)
|
||||||
|
|
||||||
|
if review is None:
|
||||||
|
self.logger.warning(
|
||||||
|
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update the KYC level based on the review, when present
|
||||||
|
if "kycLevel" in review:
|
||||||
|
new_level = review["kycLevel"]
|
||||||
|
old_level = service.get("kycLevel")
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
if update_kyc_level(service_id, new_level):
|
||||||
|
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
|
||||||
|
|
||||||
|
# Log to console
|
||||||
|
self.logger.info(msg)
|
||||||
|
|
||||||
|
# Send notification via ntfy
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"Failed to send ntfy notification for KYC level change: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return review
|
||||||
|
|
||||||
|
def get_tos_review(
|
||||||
|
self, tos_urls: list[str], current_review: Optional[TosReviewType]
|
||||||
|
) -> Optional[TosReviewType]:
|
||||||
|
"""
|
||||||
|
Get TOS review from a list of URLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tos_urls: List of TOS URLs to check
|
||||||
|
current_review: Current review data from the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- status: Literal["skipped", "failed", "success"]
|
||||||
|
- review: Optional[TosReviewType] - The review data if successful
|
||||||
|
"""
|
||||||
|
all_skipped = True
|
||||||
|
|
||||||
for tos_url in tos_urls:
|
for tos_url in tos_urls:
|
||||||
api_url = f"{tos_url}"
|
api_url = f"{tos_url}"
|
||||||
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
||||||
|
|
||||||
# Sleep for 1 second to avoid rate limiting
|
|
||||||
content = fetch_markdown(api_url)
|
content = fetch_markdown(api_url)
|
||||||
|
|
||||||
if content:
|
if not content:
|
||||||
# Hash the content to avoid repeating the same content
|
|
||||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
|
||||||
self.logger.info(f"Content hash: {content_hash}")
|
|
||||||
|
|
||||||
# service.get("tosReview") can be None if the DB field is NULL.
|
|
||||||
# Default to an empty dict to prevent AttributeError on .get()
|
|
||||||
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
|
|
||||||
"tosReview"
|
|
||||||
)
|
|
||||||
tos_review: Dict[str, Any] = (
|
|
||||||
tos_review_data_from_service
|
|
||||||
if tos_review_data_from_service is not None
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
|
|
||||||
stored_hash = tos_review.get("contentHash")
|
|
||||||
|
|
||||||
# Skip processing if we've seen this content before
|
|
||||||
if stored_hash == content_hash:
|
|
||||||
self.logger.info(
|
|
||||||
f"Skipping already processed TOS content with hash: {content_hash}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip incomplete TOS content
|
|
||||||
check = prompt_check_tos_review(content)
|
|
||||||
if not check:
|
|
||||||
continue
|
|
||||||
elif not check["isComplete"]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Query OpenAI to summarize the content
|
|
||||||
review = prompt_tos_review(content)
|
|
||||||
|
|
||||||
if review:
|
|
||||||
review["contentHash"] = content_hash
|
|
||||||
# Save the review to the database
|
|
||||||
save_tos_review(service_id, review)
|
|
||||||
|
|
||||||
# Update the KYC level based on the review
|
|
||||||
if "kycLevel" in review:
|
|
||||||
kyc_level = review["kycLevel"]
|
|
||||||
self.logger.info(
|
|
||||||
f"Updating KYC level to {kyc_level} for service {service_name}"
|
|
||||||
)
|
|
||||||
update_kyc_level(service_id, kyc_level)
|
|
||||||
# no need to check other TOS URLs
|
|
||||||
break
|
|
||||||
|
|
||||||
return review
|
|
||||||
else:
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
f"Failed to retrieve TOS content for URL: {tos_url}"
|
f"Failed to retrieve TOS content for URL: {tos_url}"
|
||||||
)
|
)
|
||||||
|
all_skipped = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Hash the content to avoid repeating the same content
|
||||||
|
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
self.logger.info(f"Content hash: {content_hash}")
|
||||||
|
|
||||||
|
# Skip processing if we've seen this content before
|
||||||
|
if current_review and current_review.get("contentHash") == content_hash:
|
||||||
|
self.logger.info(
|
||||||
|
f"Skipping already processed TOS content with hash: {content_hash}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_skipped = False
|
||||||
|
|
||||||
|
# Skip incomplete TOS content
|
||||||
|
check = prompt_check_tos_review(content)
|
||||||
|
if not check or not check["isComplete"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Query OpenAI to summarize the content
|
||||||
|
review = prompt_tos_review(content)
|
||||||
|
|
||||||
|
if review:
|
||||||
|
review["contentHash"] = content_hash
|
||||||
|
return review
|
||||||
|
|
||||||
|
if all_skipped:
|
||||||
|
return current_review
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
|
|||||||
{"role": "user", "content": content},
|
{"role": "user", "content": content},
|
||||||
]
|
]
|
||||||
|
|
||||||
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
|
result_dict = query_openai_json(
|
||||||
|
messages, model="openai/gemini-2.5-flash-preview-05-20"
|
||||||
|
)
|
||||||
|
|
||||||
return cast(TosReviewCheck, result_dict)
|
return cast(TosReviewCheck, result_dict)
|
||||||
|
|
||||||
@@ -173,27 +175,27 @@ type TosReview = {
|
|||||||
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
||||||
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
||||||
rating: 'negative' | 'neutral' | 'positive'
|
rating: 'negative' | 'neutral' | 'positive'
|
||||||
}[]
|
}[] // max 8 highlights, try to provide at least 3.
|
||||||
}
|
}
|
||||||
|
|
||||||
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
||||||
|
|
||||||
Be concise but thorough, and make sure your output is properly formatted JSON.
|
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||||
You will be given a list of user comments to a service.
|
You will be given a list of user comments to a service.
|
||||||
Your task is to summarize the comments in a way that is easy to understand and to the point.
|
Your task is to summarize the comments in a way that is easy to understand and to the point.
|
||||||
The summary should be concise and to the point, no more than 150 words.
|
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
|
||||||
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
|
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
|
||||||
|
|
||||||
You must format your response as a valid JSON object with the following structure:
|
You must format your response as a valid JSON object with the following structure:
|
||||||
|
|
||||||
interface CommentSummary {
|
interface CommentSummary {
|
||||||
summary: string;
|
summary: string; // Concise, 100 words max
|
||||||
sentiment: 'positive'|'negative'|'neutral';
|
sentiment: 'positive'|'negative'|'neutral';
|
||||||
whatUsersLike: string[]; // Concise, 2-3 words, max 4
|
whatUsersLike: string[]; // Concise, 2-3 words max
|
||||||
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
|
whatUsersDislike: string[]; // Concise, 2-3 words max
|
||||||
}
|
}
|
||||||
|
|
||||||
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.
|
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
23
|
24
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
FROM node:lts AS runtime
|
FROM node:lts AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY .env .env
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
|
||||||
COPY .npmrc .npmrc
|
COPY web/.npmrc .npmrc
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY web/ .
|
||||||
|
|
||||||
ARG ASTRO_BUILD_MODE=production
|
ARG ASTRO_BUILD_MODE=production
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
# Add knm-migrate command script and make it executable
|
# Add knm-migrate command script and make it executable
|
||||||
COPY migrate.sh /usr/local/bin/knm-migrate
|
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
||||||
RUN chmod +x /usr/local/bin/knm-migrate
|
RUN chmod +x /usr/local/bin/knm-migrate
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]
|
||||||
|
|||||||
@@ -4,27 +4,82 @@ import mdx from '@astrojs/mdx'
|
|||||||
import node from '@astrojs/node'
|
import node from '@astrojs/node'
|
||||||
import sitemap from '@astrojs/sitemap'
|
import sitemap from '@astrojs/sitemap'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||||
|
import AstroPWA from '@vite-pwa/astro'
|
||||||
import { defineConfig, envField } from 'astro/config'
|
import { defineConfig, envField } from 'astro/config'
|
||||||
import icon from 'astro-icon'
|
import icon from 'astro-icon'
|
||||||
|
import devtoolsJson from 'vite-plugin-devtools-json'
|
||||||
|
|
||||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||||
|
|
||||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||||
|
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
|
||||||
|
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: SITE_URL,
|
site: SITE_URL,
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: [tailwindcss()],
|
plugins: [devtoolsJson(), tailwindcss()],
|
||||||
},
|
},
|
||||||
integrations: [
|
integrations: [
|
||||||
postgresListener(),
|
postgresListener(),
|
||||||
icon(),
|
icon(),
|
||||||
mdx(),
|
mdx(),
|
||||||
|
AstroPWA({
|
||||||
|
mode: 'development',
|
||||||
|
base: '/',
|
||||||
|
scope: '/',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'KYCnot.me',
|
||||||
|
short_name: 'KYCnot.me',
|
||||||
|
description: 'Find services that respect your privacy',
|
||||||
|
theme_color: '#040505',
|
||||||
|
background_color: '#171c1b',
|
||||||
|
display: 'minimal-ui',
|
||||||
|
},
|
||||||
|
pwaAssets: {
|
||||||
|
image: './public/favicon.svg',
|
||||||
|
preset: {
|
||||||
|
...minimal2023Preset,
|
||||||
|
maskable: {
|
||||||
|
...minimal2023Preset.maskable,
|
||||||
|
padding: 0.1,
|
||||||
|
resizeOptions: {
|
||||||
|
...minimal2023Preset.maskable.resizeOptions,
|
||||||
|
background: '#3bdb78',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apple: {
|
||||||
|
...minimal2023Preset.apple,
|
||||||
|
padding: 0.1,
|
||||||
|
resizeOptions: {
|
||||||
|
...minimal2023Preset.apple.resizeOptions,
|
||||||
|
background: '#3bdb78',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/404',
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
|
||||||
|
},
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
directoryAndTrailingSlashHandler: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => {
|
filter: (page) => {
|
||||||
const url = new URL(page)
|
const url = new URL(page)
|
||||||
@@ -42,6 +97,19 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
open: false,
|
open: false,
|
||||||
allowedHosts: [new URL(SITE_URL).hostname],
|
allowedHosts: [new URL(SITE_URL).hostname],
|
||||||
|
headers: {
|
||||||
|
'Onion-Location': ONION_ADDRESS,
|
||||||
|
'X-I2P-Location': I2P_ADDRESS,
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
// Astro is working on this feature, when it's stable use it instead of this.
|
||||||
|
// https://astro.build/blog/astro-590/#experimental-content-security-policy-support
|
||||||
|
'Content-Security-Policy':
|
||||||
|
SITE_URL === 'http://localhost:4321'
|
||||||
|
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
||||||
|
: "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests",
|
||||||
|
'Strict-Transport-Security':
|
||||||
|
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
domains: [new URL(SITE_URL).hostname],
|
domains: [new URL(SITE_URL).hostname],
|
||||||
@@ -57,6 +125,8 @@ export default defineConfig({
|
|||||||
'/attribute/[...slug]': '/attributes',
|
'/attribute/[...slug]': '/attributes',
|
||||||
'/attr/[...slug]': '/attributes',
|
'/attr/[...slug]': '/attributes',
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
schema: {
|
schema: {
|
||||||
@@ -70,7 +140,7 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
// Public URLs (can be accessed from both server and client)
|
// Public URLs (can be accessed from both server and client)
|
||||||
SOURCE_CODE_URL: envField.string({
|
SOURCE_CODE_URL: envField.string({
|
||||||
context: 'server',
|
context: 'client',
|
||||||
access: 'public',
|
access: 'public',
|
||||||
url: true,
|
url: true,
|
||||||
optional: false,
|
optional: false,
|
||||||
@@ -95,35 +165,6 @@ export default defineConfig({
|
|||||||
startsWith: 'redis://',
|
startsWith: 'redis://',
|
||||||
default: 'redis://redis:6379',
|
default: 'redis://redis:6379',
|
||||||
}),
|
}),
|
||||||
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 60 * 24, // 24 hours in seconds
|
|
||||||
}),
|
|
||||||
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 60 * 24, // 24 hours in seconds
|
|
||||||
}),
|
|
||||||
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 5, // 5 minutes in seconds
|
|
||||||
}),
|
|
||||||
|
|
||||||
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
|
|
||||||
context: 'server',
|
|
||||||
access: 'secret',
|
|
||||||
int: true,
|
|
||||||
gt: 0,
|
|
||||||
default: 60 * 5, // 5 minutes in seconds
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Development tokens
|
// Development tokens
|
||||||
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default tseslint.config(
|
|||||||
'import/first': 'error',
|
'import/first': 'error',
|
||||||
'import/newline-after-import': 'error',
|
'import/newline-after-import': 'error',
|
||||||
'import/no-duplicates': 'error',
|
'import/no-duplicates': 'error',
|
||||||
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
|
'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
||||||
'import/namespace': 'off',
|
'import/namespace': 'off',
|
||||||
|
|||||||
5730
web/package-lock.json
generated
@@ -4,8 +4,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build --remote",
|
"build": "npm run build:astro && npm run build:server-init",
|
||||||
"preview": "astro preview",
|
"build:astro": "astro build --remote",
|
||||||
|
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
|
||||||
|
"preview": "node dist/server/server-init.js & astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"db-admin": "prisma studio --browser=none",
|
"db-admin": "prisma studio --browser=none",
|
||||||
"db-gen": "prisma generate",
|
"db-gen": "prisma generate",
|
||||||
@@ -23,79 +25,89 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "0.9.4",
|
"@astrojs/check": "0.9.4",
|
||||||
"@astrojs/db": "0.14.14",
|
"@astrojs/db": "0.15.0",
|
||||||
"@astrojs/mdx": "4.2.6",
|
"@astrojs/mdx": "4.3.0",
|
||||||
"@astrojs/node": "9.2.1",
|
"@astrojs/node": "9.3.0",
|
||||||
"@astrojs/sitemap": "3.4.0",
|
"@astrojs/rss": "4.0.12",
|
||||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
"@astrojs/sitemap": "3.4.1",
|
||||||
"@fontsource/inter": "5.2.5",
|
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||||
"@fontsource/space-grotesk": "5.2.7",
|
"@fontsource/inter": "5.2.6",
|
||||||
"@prisma/client": "6.8.2",
|
"@fontsource/space-grotesk": "5.2.8",
|
||||||
"@tailwindcss/vite": "4.1.7",
|
"@prisma/client": "6.11.1",
|
||||||
"@types/mime-types": "2.1.4",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/pg": "8.15.4",
|
"@types/pg": "8.15.4",
|
||||||
"@vercel/og": "0.6.8",
|
"@vercel/og": "0.7.2",
|
||||||
"astro": "5.7.13",
|
"astro": "5.9.0",
|
||||||
"astro-loading-indicator": "0.7.0",
|
"astro-loading-indicator": "0.7.0",
|
||||||
"astro-remote": "0.3.4",
|
"astro-remote": "0.3.4",
|
||||||
"astro-seo-schema": "5.0.0",
|
"astro-seo-schema": "5.0.0",
|
||||||
"canvas": "3.1.0",
|
"canvas": "3.1.2",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"htmx.org": "1.9.12",
|
"countries-list": "3.1.1",
|
||||||
|
"country-flag-icons": "1.5.19",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"htmx.org": "2.0.6",
|
||||||
"javascript-time-ago": "2.5.11",
|
"javascript-time-ago": "2.5.11",
|
||||||
"libphonenumber-js": "1.12.8",
|
"libphonenumber-js": "1.12.9",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"mime-types": "3.0.1",
|
"mime-types": "3.0.1",
|
||||||
"object-to-formdata": "4.5.1",
|
"object-to-formdata": "4.5.1",
|
||||||
"pg": "8.16.0",
|
"pg": "8.16.3",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"redis": "5.0.1",
|
"redis": "5.6.0",
|
||||||
"schema-dts": "1.1.5",
|
"schema-dts": "1.1.5",
|
||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
"sharp": "0.34.1",
|
"sharp": "0.34.2",
|
||||||
"slugify": "1.6.6",
|
"slugify": "1.6.6",
|
||||||
"tailwind-merge": "3.3.0",
|
"tailwind-merge": "3.3.1",
|
||||||
"tailwind-variants": "1.0.0",
|
"tailwind-variants": "1.0.0",
|
||||||
"tailwindcss": "4.1.7",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"unique-username-generator": "1.4.0",
|
"unique-username-generator": "1.4.0",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7"
|
||||||
"zod-form-data": "2.0.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "9.27.0",
|
"@eslint/js": "9.30.1",
|
||||||
"@faker-js/faker": "9.8.0",
|
"@faker-js/faker": "9.9.0",
|
||||||
"@iconify-json/material-symbols": "1.2.21",
|
"@iconify-json/material-symbols": "1.2.29",
|
||||||
"@iconify-json/mdi": "1.2.3",
|
"@iconify-json/mdi": "1.2.3",
|
||||||
"@iconify-json/ri": "1.2.5",
|
"@iconify-json/ri": "1.2.5",
|
||||||
"@stylistic/eslint-plugin": "4.2.0",
|
"@stylistic/eslint-plugin": "5.1.0",
|
||||||
"@tailwindcss/forms": "0.5.10",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/eslint__js": "9.14.0",
|
"@types/eslint__js": "9.14.0",
|
||||||
|
"@types/he": "1.2.3",
|
||||||
"@types/lodash-es": "4.17.12",
|
"@types/lodash-es": "4.17.12",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/react": "19.1.4",
|
"@types/react": "19.1.8",
|
||||||
"@types/seedrandom": "3.0.8",
|
"@types/seedrandom": "3.0.8",
|
||||||
"@types/web-push": "3.6.4",
|
"@types/web-push": "3.6.4",
|
||||||
"@typescript-eslint/parser": "8.32.1",
|
"@typescript-eslint/parser": "8.36.0",
|
||||||
|
"@vite-pwa/assets-generator": "1.0.0",
|
||||||
|
"@vite-pwa/astro": "1.1.0",
|
||||||
"astro-icon": "1.1.5",
|
"astro-icon": "1.1.5",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"eslint": "9.27.0",
|
"esbuild": "0.25.6",
|
||||||
"eslint-import-resolver-typescript": "4.3.5",
|
"eslint": "9.30.1",
|
||||||
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
"eslint-plugin-astro": "1.3.1",
|
"eslint-plugin-astro": "1.3.1",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"globals": "16.1.0",
|
"globals": "16.3.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.6.2",
|
||||||
"prettier-plugin-astro": "0.14.1",
|
"prettier-plugin-astro": "0.14.1",
|
||||||
"prettier-plugin-tailwindcss": "0.6.11",
|
"prettier-plugin-tailwindcss": "0.6.13",
|
||||||
"prisma": "6.8.2",
|
"prisma": "6.11.1",
|
||||||
"prisma-json-types-generator": "3.4.1",
|
"prisma-json-types-generator": "3.5.1",
|
||||||
"tailwind-htmx": "0.1.2",
|
"tailwind-htmx": "0.1.2",
|
||||||
"ts-essentials": "10.0.4",
|
"ts-essentials": "10.1.1",
|
||||||
"ts-toolbelt": "9.6.0",
|
"ts-toolbelt": "9.6.0",
|
||||||
"tsx": "4.19.4",
|
"tsx": "4.20.3",
|
||||||
"typescript-eslint": "8.32.1"
|
"typescript-eslint": "8.36.0",
|
||||||
|
"vite-plugin-devtools-json": "0.2.1",
|
||||||
|
"workbox-core": "7.3.0",
|
||||||
|
"workbox-precaching": "7.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
|
||||||
|
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_30_DAYS';
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_15_DAYS';
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_5_DAYS';
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_1_DAY';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "scheduledDeletionAt" DATE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;
|
||||||
@@ -25,6 +25,7 @@ enum OrderIdStatus {
|
|||||||
PENDING
|
PENDING
|
||||||
APPROVED
|
APPROVED
|
||||||
REJECTED
|
REJECTED
|
||||||
|
WITHDRAWN
|
||||||
}
|
}
|
||||||
|
|
||||||
model Comment {
|
model Comment {
|
||||||
@@ -128,11 +129,13 @@ enum AccountStatusChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
|
TEST
|
||||||
COMMENT_STATUS_CHANGE
|
COMMENT_STATUS_CHANGE
|
||||||
REPLY_COMMENT_CREATED
|
REPLY_COMMENT_CREATED
|
||||||
COMMUNITY_NOTE_ADDED
|
COMMUNITY_NOTE_ADDED
|
||||||
/// Comment that is not a reply. May include a rating.
|
/// Comment that is not a reply. May include a rating.
|
||||||
ROOT_COMMENT_CREATED
|
ROOT_COMMENT_CREATED
|
||||||
|
SUGGESTION_CREATED
|
||||||
SUGGESTION_MESSAGE
|
SUGGESTION_MESSAGE
|
||||||
SUGGESTION_STATUS_CHANGE
|
SUGGESTION_STATUS_CHANGE
|
||||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||||
@@ -141,6 +144,10 @@ enum NotificationType {
|
|||||||
ACCOUNT_STATUS_CHANGE
|
ACCOUNT_STATUS_CHANGE
|
||||||
EVENT_CREATED
|
EVENT_CREATED
|
||||||
SERVICE_VERIFICATION_STATUS_CHANGE
|
SERVICE_VERIFICATION_STATUS_CHANGE
|
||||||
|
ACCOUNT_DELETION_WARNING_30_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_15_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_5_DAYS
|
||||||
|
ACCOUNT_DELETION_WARNING_1_DAY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CommentStatusChange {
|
enum CommentStatusChange {
|
||||||
@@ -338,53 +345,64 @@ model ServiceSuggestionMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Service {
|
model Service {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
previousSlugs String[] @default([])
|
previousSlugs String[] @default([])
|
||||||
description String
|
description String
|
||||||
categories Category[] @relation("ServiceToCategory")
|
categories Category[] @relation("ServiceToCategory")
|
||||||
kycLevel Int @default(4)
|
kycLevel Int @default(4)
|
||||||
kycLevelClarification KycLevelClarification @default(NONE)
|
kycLevelClarification KycLevelClarification @default(NONE)
|
||||||
overallScore Int @default(0)
|
/// Date only, no time.
|
||||||
privacyScore Int @default(0)
|
operatingSince DateTime? @db.Date
|
||||||
trustScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
|
privacyScore Int @default(0)
|
||||||
|
trustScore Int @default(0)
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
isRecentlyListed Boolean @default(false)
|
averageUserRating Float?
|
||||||
/// Computed via trigger. Do not update through prisma.
|
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||||
averageUserRating Float?
|
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
serviceInfoBannerNotes String?
|
||||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||||
serviceInfoBannerNotes String?
|
verificationSummary String?
|
||||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
verificationRequests ServiceVerificationRequest[]
|
||||||
verificationSummary String?
|
verificationProofMd String?
|
||||||
verificationRequests ServiceVerificationRequest[]
|
|
||||||
verificationProofMd String?
|
|
||||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
|
||||||
verifiedAt DateTime?
|
|
||||||
/// [UserSentiment]
|
/// [UserSentiment]
|
||||||
userSentiment Json?
|
userSentiment Json?
|
||||||
userSentimentAt DateTime?
|
userSentimentAt DateTime?
|
||||||
referral String?
|
referral String?
|
||||||
acceptedCurrencies Currency[] @default([])
|
acceptedCurrencies Currency[] @default([])
|
||||||
serviceUrls String[]
|
serviceUrls String[]
|
||||||
tosUrls String[] @default([])
|
tosUrls String[] @default([])
|
||||||
onionUrls String[] @default([])
|
onionUrls String[] @default([])
|
||||||
i2pUrls String[] @default([])
|
i2pUrls String[] @default([])
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
/// ISO 3166-1 alpha-2 country code where the service company is registered
|
||||||
|
registrationCountryCode String? @db.VarChar(2)
|
||||||
|
/// Official name of the registered company
|
||||||
|
registeredCompanyName String?
|
||||||
/// [TosReview]
|
/// [TosReview]
|
||||||
tosReview Json?
|
tosReview Json?
|
||||||
tosReviewAt DateTime?
|
tosReviewAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
listedAt DateTime?
|
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||||
comments Comment[]
|
listedAt DateTime?
|
||||||
events Event[]
|
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
approvedAt DateTime?
|
||||||
attributes ServiceAttribute[]
|
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||||
verificationSteps VerificationStep[]
|
verifiedAt DateTime?
|
||||||
suggestions ServiceSuggestion[]
|
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
spamAt DateTime?
|
||||||
|
/// Computed via trigger. Do not update through prisma.
|
||||||
|
isRecentlyApproved Boolean @default(false)
|
||||||
|
comments Comment[]
|
||||||
|
events Event[]
|
||||||
|
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||||
|
attributes ServiceAttribute[]
|
||||||
|
verificationSteps VerificationStep[]
|
||||||
|
suggestions ServiceSuggestion[]
|
||||||
|
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||||
|
|
||||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||||
@@ -392,7 +410,13 @@ model Service {
|
|||||||
Notification Notification[]
|
Notification Notification[]
|
||||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||||
|
|
||||||
|
strictCommentingEnabled Boolean @default(false)
|
||||||
|
commentSectionMessage String?
|
||||||
|
|
||||||
@@index([listedAt])
|
@@index([listedAt])
|
||||||
|
@@index([approvedAt])
|
||||||
|
@@index([verifiedAt])
|
||||||
|
@@index([spamAt])
|
||||||
@@index([overallScore])
|
@@index([overallScore])
|
||||||
@@index([privacyScore])
|
@@index([privacyScore])
|
||||||
@@index([trustScore])
|
@@index([trustScore])
|
||||||
@@ -404,6 +428,7 @@ model Service {
|
|||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([previousSlugs])
|
@@index([previousSlugs])
|
||||||
|
@@index([serviceVisibility])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ServiceContactMethod {
|
model ServiceContactMethod {
|
||||||
@@ -484,19 +509,22 @@ model InternalServiceNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
displayName String?
|
displayName String?
|
||||||
link String?
|
link String?
|
||||||
picture String?
|
picture String?
|
||||||
spammer Boolean @default(false)
|
spammer Boolean @default(false)
|
||||||
verified Boolean @default(false)
|
verified Boolean @default(false)
|
||||||
admin Boolean @default(false)
|
admin Boolean @default(false)
|
||||||
moderator Boolean @default(false)
|
moderator Boolean @default(false)
|
||||||
verifiedLink String?
|
verifiedLink String?
|
||||||
secretTokenHash String @unique
|
secretTokenHash String @unique
|
||||||
|
feedId String @unique @default(cuid(2))
|
||||||
/// Computed via trigger. Do not update through prisma.
|
/// Computed via trigger. Do not update through prisma.
|
||||||
totalKarma Int @default(0)
|
totalKarma Int @default(0)
|
||||||
|
/// Date when user is scheduled for deletion due to inactivity
|
||||||
|
scheduledDeletionAt DateTime? @db.Date
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -574,6 +602,7 @@ enum VerificationStepStatus {
|
|||||||
IN_PROGRESS
|
IN_PROGRESS
|
||||||
PASSED
|
PASSED
|
||||||
FAILED
|
FAILED
|
||||||
|
WARNING
|
||||||
}
|
}
|
||||||
|
|
||||||
model VerificationStep {
|
model VerificationStep {
|
||||||
@@ -593,10 +622,11 @@ model VerificationStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
icon String
|
namePluralLong String?
|
||||||
slug String @unique
|
icon String
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -673,8 +703,6 @@ model PushSubscription {
|
|||||||
p256dh String
|
p256dh String
|
||||||
/// Authentication secret
|
/// Authentication secret
|
||||||
auth String
|
auth String
|
||||||
/// To identify different devices
|
|
||||||
userAgent String?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -13,19 +13,22 @@ import {
|
|||||||
PrismaClient,
|
PrismaClient,
|
||||||
ServiceSuggestionStatus,
|
ServiceSuggestionStatus,
|
||||||
ServiceUserRole,
|
ServiceUserRole,
|
||||||
VerificationStatus,
|
|
||||||
type Prisma,
|
type Prisma,
|
||||||
type User,
|
type User,
|
||||||
type ServiceVisibility,
|
type ServiceVisibility,
|
||||||
ServiceSuggestionType,
|
ServiceSuggestionType,
|
||||||
KycLevelClarification,
|
KycLevelClarification,
|
||||||
|
VerificationStepStatus,
|
||||||
|
type VerificationStatus,
|
||||||
} from '@prisma/client'
|
} from '@prisma/client'
|
||||||
|
import { differenceInDays, isPast } from 'date-fns'
|
||||||
import { omit, uniqBy } from 'lodash-es'
|
import { omit, uniqBy } from 'lodash-es'
|
||||||
import { generateUsername } from 'unique-username-generator'
|
import { generateUsername } from 'unique-username-generator'
|
||||||
|
|
||||||
import { kycLevels } from '../src/constants/kycLevels'
|
import { kycLevels } from '../src/constants/kycLevels'
|
||||||
import { undefinedIfEmpty } from '../src/lib/arrays'
|
import { undefinedIfEmpty } from '../src/lib/arrays'
|
||||||
import { transformCase } from '../src/lib/strings'
|
import { transformCase } from '../src/lib/strings'
|
||||||
|
import { countries } from '../src/constants/countries'
|
||||||
|
|
||||||
// Exit if not in development mode
|
// Exit if not in development mode
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -139,81 +142,97 @@ const generateFakeAttribute = () => {
|
|||||||
const categoriesToCreate = [
|
const categoriesToCreate = [
|
||||||
{
|
{
|
||||||
name: 'Exchange',
|
name: 'Exchange',
|
||||||
|
namePluralLong: 'Exchanges',
|
||||||
slug: 'exchange',
|
slug: 'exchange',
|
||||||
icon: 'ri:arrow-left-right-fill',
|
icon: 'ri:arrow-left-right-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'VPN',
|
name: 'VPN',
|
||||||
|
namePluralLong: 'VPNs',
|
||||||
slug: 'vpn',
|
slug: 'vpn',
|
||||||
icon: 'ri:door-lock-fill',
|
icon: 'ri:door-lock-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Email',
|
name: 'Email',
|
||||||
|
namePluralLong: 'Email providers',
|
||||||
slug: 'email',
|
slug: 'email',
|
||||||
icon: 'ri:mail-fill',
|
icon: 'ri:mail-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Hosting',
|
name: 'Hosting',
|
||||||
|
namePluralLong: 'Hostings',
|
||||||
slug: 'hosting',
|
slug: 'hosting',
|
||||||
icon: 'ri:server-fill',
|
icon: 'ri:server-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'VPS',
|
name: 'VPS',
|
||||||
|
namePluralLong: 'VPS providers',
|
||||||
slug: 'vps',
|
slug: 'vps',
|
||||||
icon: 'ri:function-add-fill',
|
icon: 'ri:function-add-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Gift Cards',
|
name: 'Gift Cards',
|
||||||
|
namePluralLong: 'Gift cards',
|
||||||
slug: 'gift-cards',
|
slug: 'gift-cards',
|
||||||
icon: 'ri:gift-line',
|
icon: 'ri:gift-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Goods',
|
name: 'Goods',
|
||||||
|
namePluralLong: 'Goods providers',
|
||||||
slug: 'goods',
|
slug: 'goods',
|
||||||
icon: 'ri:shopping-basket-fill',
|
icon: 'ri:shopping-basket-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Travel',
|
name: 'Travel',
|
||||||
|
namePluralLong: 'Travel services',
|
||||||
slug: 'travel',
|
slug: 'travel',
|
||||||
icon: 'ri:plane-fill',
|
icon: 'ri:plane-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'SMS',
|
name: 'SMS',
|
||||||
|
namePluralLong: 'SMS providers',
|
||||||
slug: 'sms',
|
slug: 'sms',
|
||||||
icon: 'ri:message-2-fill',
|
icon: 'ri:message-2-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Store',
|
name: 'Store',
|
||||||
|
namePluralLong: 'Stores',
|
||||||
slug: 'store',
|
slug: 'store',
|
||||||
icon: 'ri:store-2-line',
|
icon: 'ri:store-2-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tool',
|
name: 'Tool',
|
||||||
|
namePluralLong: 'Tools',
|
||||||
slug: 'tool',
|
slug: 'tool',
|
||||||
icon: 'ri:tools-fill',
|
icon: 'ri:tools-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Market',
|
name: 'Market',
|
||||||
|
namePluralLong: 'Markets',
|
||||||
slug: 'market',
|
slug: 'market',
|
||||||
icon: 'ri:price-tag-3-line',
|
icon: 'ri:price-tag-3-line',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Aggregator',
|
name: 'Aggregator',
|
||||||
|
namePluralLong: 'Aggregators',
|
||||||
slug: 'aggregator',
|
slug: 'aggregator',
|
||||||
icon: 'ri:list-ordered',
|
icon: 'ri:list-ordered',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AI',
|
name: 'AI',
|
||||||
|
namePluralLong: 'AI services',
|
||||||
slug: 'ai',
|
slug: 'ai',
|
||||||
icon: 'ri:ai-generate-2',
|
icon: 'ri:ai-generate-2',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CEX',
|
name: 'CEX',
|
||||||
|
namePluralLong: 'CEXs',
|
||||||
slug: 'cex',
|
slug: 'cex',
|
||||||
icon: 'ri:rotate-lock-fill',
|
icon: 'ri:rotate-lock-fill',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'DEX',
|
name: 'DEX',
|
||||||
|
namePluralLong: 'DEXs',
|
||||||
slug: 'dex',
|
slug: 'dex',
|
||||||
icon: 'ri:fediverse-line',
|
icon: 'ri:fediverse-line',
|
||||||
},
|
},
|
||||||
@@ -610,6 +629,18 @@ const generateFakeService = (users: User[]) => {
|
|||||||
const name = faker.helpers.arrayElement(serviceNames)
|
const name = faker.helpers.arrayElement(serviceNames)
|
||||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||||
|
|
||||||
|
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
|
||||||
|
probability: 0.8,
|
||||||
|
})
|
||||||
|
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||||
|
{ weight: 80, value: 'PUBLIC' },
|
||||||
|
{ weight: 10, value: 'UNLISTED' },
|
||||||
|
{ weight: 5, value: 'HIDDEN' },
|
||||||
|
{ weight: 5, value: 'ARCHIVED' },
|
||||||
|
])
|
||||||
|
const approvedAt =
|
||||||
|
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
@@ -624,12 +655,7 @@ const generateFakeService = (users: User[]) => {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
serviceVisibility,
|
||||||
{ weight: 80, value: 'PUBLIC' },
|
|
||||||
{ weight: 10, value: 'UNLISTED' },
|
|
||||||
{ weight: 5, value: 'HIDDEN' },
|
|
||||||
{ weight: 5, value: 'ARCHIVED' },
|
|
||||||
]),
|
|
||||||
verificationStatus: status,
|
verificationStatus: status,
|
||||||
verificationSummary:
|
verificationSummary:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||||
@@ -643,6 +669,19 @@ const generateFakeService = (users: User[]) => {
|
|||||||
},
|
},
|
||||||
verificationProofMd:
|
verificationProofMd:
|
||||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||||
|
verificationSteps:
|
||||||
|
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
|
||||||
|
? {
|
||||||
|
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
|
||||||
|
title: faker.lorem.sentence(),
|
||||||
|
description: faker.lorem.paragraph(),
|
||||||
|
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
|
||||||
|
evidenceMd: faker.lorem.paragraph(),
|
||||||
|
createdAt: faker.date.recent(),
|
||||||
|
updatedAt: faker.date.recent(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
referral: faker.helpers.arrayElement([
|
referral: faker.helpers.arrayElement([
|
||||||
`?ref=${faker.string.alphanumeric(6)}`,
|
`?ref=${faker.string.alphanumeric(6)}`,
|
||||||
`/ref/${faker.string.alphanumeric(6)}`,
|
`/ref/${faker.string.alphanumeric(6)}`,
|
||||||
@@ -659,10 +698,24 @@ const generateFakeService = (users: User[]) => {
|
|||||||
{ count: { min: 0, max: 2 } }
|
{ count: { min: 0, max: 2 } }
|
||||||
),
|
),
|
||||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||||
listedAt: faker.date.past(),
|
registrationCountryCode: faker.helpers.maybe(() => faker.helpers.arrayElement(countries).code, {
|
||||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
probability: 0.7,
|
||||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
}),
|
||||||
tosReviewAt: faker.date.past(),
|
registeredCompanyName: faker.helpers.maybe(() => faker.company.name(), {
|
||||||
|
probability: 0.6,
|
||||||
|
}),
|
||||||
|
listedAt:
|
||||||
|
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
|
||||||
|
? faker.date.recent({ days: 30 })
|
||||||
|
: null,
|
||||||
|
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
|
||||||
|
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
|
||||||
|
approvedAt,
|
||||||
|
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
|
||||||
|
tosReview,
|
||||||
|
tosReviewAt: tosReview
|
||||||
|
? faker.date.recent()
|
||||||
|
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
|
||||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||||
userSentimentAt: faker.date.recent(),
|
userSentimentAt: faker.date.recent(),
|
||||||
internalNotes: faker.helpers.maybe(
|
internalNotes: faker.helpers.maybe(
|
||||||
@@ -674,6 +727,8 @@ const generateFakeService = (users: User[]) => {
|
|||||||
}),
|
}),
|
||||||
{ probability: 0.33 }
|
{ probability: 0.33 }
|
||||||
),
|
),
|
||||||
|
strictCommentingEnabled: faker.datatype.boolean(0.33333),
|
||||||
|
commentSectionMessage: faker.helpers.maybe(() => faker.lorem.paragraph(), { probability: 0.3 }),
|
||||||
} as const satisfies Prisma.ServiceCreateInput
|
} as const satisfies Prisma.ServiceCreateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +943,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: faker.lorem.word({ length: 2 }),
|
label: 'Custom label',
|
||||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -898,7 +953,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
|||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: faker.lorem.word({ length: 2 }),
|
label: 'Custom label',
|
||||||
value: faker.internet.url(),
|
value: faker.internet.url(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1123,7 +1178,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let users = await Promise.all(
|
let users = await Promise.all(
|
||||||
Array.from({ length: 10 }, async () => {
|
Array.from({ length: 570 }, async () => {
|
||||||
const { user } = await createAccount()
|
const { user } = await createAccount()
|
||||||
return user
|
return user
|
||||||
})
|
})
|
||||||
@@ -1287,7 +1342,7 @@ async function main() {
|
|||||||
const service = await prisma.service.create({
|
const service = await prisma.service.create({
|
||||||
data: {
|
data: {
|
||||||
...serviceData,
|
...serviceData,
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_related_to_service BOOLEAN;
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||||
PERFORM insert_karma_transaction(
|
-- Check if the user is related to the service (e.g., owns/manages it)
|
||||||
NEW."authorId",
|
SELECT EXISTS(
|
||||||
1,
|
SELECT 1 FROM "ServiceUser"
|
||||||
'COMMENT_APPROVED',
|
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
|
||||||
NEW.id,
|
) INTO is_user_related_to_service;
|
||||||
format('Your comment #comment-%s in %s has been approved!',
|
|
||||||
NEW.id,
|
-- Check if the user is an admin or moderator
|
||||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
SELECT (admin = true OR moderator = true)
|
||||||
);
|
FROM "User"
|
||||||
PERFORM update_user_karma(NEW."authorId", 1);
|
WHERE id = NEW."authorId"
|
||||||
|
INTO is_user_admin_or_moderator;
|
||||||
|
|
||||||
|
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
|
||||||
|
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||||
|
PERFORM insert_karma_transaction(
|
||||||
|
NEW."authorId",
|
||||||
|
1,
|
||||||
|
'COMMENT_APPROVED',
|
||||||
|
NEW.id,
|
||||||
|
format('Your comment #comment-%s in %s has been approved!',
|
||||||
|
NEW.id,
|
||||||
|
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||||
|
);
|
||||||
|
PERFORM update_user_karma(NEW."authorId", 1);
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||||
PERFORM insert_karma_transaction(
|
-- Check if the comment author is an admin or moderator
|
||||||
NEW."authorId",
|
SELECT (admin = true OR moderator = true)
|
||||||
5,
|
FROM "User"
|
||||||
'COMMENT_VERIFIED',
|
WHERE id = NEW."authorId"
|
||||||
NEW.id,
|
INTO is_user_admin_or_moderator;
|
||||||
format('Your comment #comment-%s in %s has been verified!',
|
|
||||||
NEW.id,
|
-- Only award karma if the user is NOT an admin/moderator
|
||||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||||
);
|
PERFORM insert_karma_transaction(
|
||||||
PERFORM update_user_karma(NEW."authorId", 5);
|
NEW."authorId",
|
||||||
|
5,
|
||||||
|
'COMMENT_VERIFIED',
|
||||||
|
NEW.id,
|
||||||
|
format('Your comment #comment-%s in %s has been verified!',
|
||||||
|
NEW.id,
|
||||||
|
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||||
|
);
|
||||||
|
PERFORM update_user_karma(NEW."authorId", 5);
|
||||||
|
END IF;
|
||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -146,12 +175,19 @@ DECLARE
|
|||||||
comment_author_id INT;
|
comment_author_id INT;
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||||
|
is_author_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get comment author and service info
|
-- Get comment author and service info
|
||||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||||
FROM "Comment" c
|
FROM "Comment" c
|
||||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
||||||
|
|
||||||
|
-- Check if the comment author is an admin or moderator
|
||||||
|
SELECT (admin = true OR moderator = true)
|
||||||
|
FROM "User"
|
||||||
|
WHERE id = comment_author_id
|
||||||
|
INTO is_author_admin_or_moderator;
|
||||||
|
|
||||||
-- Calculate karma impact based on vote type
|
-- Calculate karma impact based on vote type
|
||||||
IF TG_OP = 'INSERT' THEN
|
IF TG_OP = 'INSERT' THEN
|
||||||
-- New vote
|
-- New vote
|
||||||
@@ -181,16 +217,19 @@ BEGIN
|
|||||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Record karma transaction and update user karma
|
-- Only award karma if the author is NOT an admin/moderator
|
||||||
PERFORM insert_karma_transaction(
|
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
|
||||||
comment_author_id,
|
-- Record karma transaction and update user karma
|
||||||
karma_points,
|
PERFORM insert_karma_transaction(
|
||||||
vote_action,
|
comment_author_id,
|
||||||
COALESCE(NEW."commentId", OLD."commentId"),
|
karma_points,
|
||||||
vote_description
|
vote_action,
|
||||||
);
|
COALESCE(NEW."commentId", OLD."commentId"),
|
||||||
|
vote_description
|
||||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
);
|
||||||
|
|
||||||
|
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Update comment's upvotes count incrementally
|
-- Update comment's upvotes count incrementally
|
||||||
UPDATE "Comment"
|
UPDATE "Comment"
|
||||||
@@ -236,26 +275,40 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
|||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
|
service_visibility "ServiceVisibility";
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Award karma for first approval
|
-- Award karma for first approval
|
||||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||||
-- and ensure it wasn't already APPROVED.
|
-- and ensure it wasn't already APPROVED.
|
||||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||||
-- Fetch service name for the description
|
-- Fetch service details for the description
|
||||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
SELECT name, "serviceVisibility" INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
|
||||||
|
|
||||||
|
-- Only award karma if the service is public
|
||||||
|
IF service_visibility = 'PUBLIC' THEN
|
||||||
|
-- Check if the user is an admin or moderator
|
||||||
|
SELECT (admin = true OR moderator = true)
|
||||||
|
FROM "User"
|
||||||
|
WHERE id = NEW."userId"
|
||||||
|
INTO is_user_admin_or_moderator;
|
||||||
|
|
||||||
|
-- Only award karma if the user is NOT an admin/moderator
|
||||||
|
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||||
|
-- Insert karma transaction, linking it to the suggestion
|
||||||
|
PERFORM insert_karma_transaction(
|
||||||
|
NEW."userId",
|
||||||
|
10,
|
||||||
|
'SUGGESTION_APPROVED',
|
||||||
|
NULL, -- p_comment_id (not applicable)
|
||||||
|
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||||
|
NEW.id -- p_suggestion_id
|
||||||
|
);
|
||||||
|
|
||||||
-- Insert karma transaction, linking it to the suggestion
|
-- Update user's total karma
|
||||||
PERFORM insert_karma_transaction(
|
PERFORM update_user_karma(NEW."userId", 10);
|
||||||
NEW."userId",
|
END IF;
|
||||||
10,
|
END IF;
|
||||||
'SUGGESTION_APPROVED',
|
|
||||||
NULL, -- p_comment_id (not applicable)
|
|
||||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
|
||||||
NEW.id -- p_suggestion_id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Update user's total karma
|
|
||||||
PERFORM update_user_karma(NEW."userId", 10);
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||||
-- for services when services or their attributes change.
|
-- for services when services or their attributes change.
|
||||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
|
||||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||||
-- efficient handling of widespread score updates.
|
-- efficient handling of widespread score updates.
|
||||||
@@ -22,14 +22,11 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
|||||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
privacy_score INT := 50; -- Start from middle value (50)
|
privacy_score INT := 0;
|
||||||
kyc_factor INT;
|
kyc_factor INT;
|
||||||
onion_factor INT := 0;
|
clarification_factor INT := 0;
|
||||||
i2p_factor INT := 0;
|
onion_or_i2p_factor INT := 0;
|
||||||
monero_factor INT := 0;
|
monero_factor INT := 0;
|
||||||
open_source_factor INT := 0;
|
|
||||||
p2p_factor INT := 0;
|
|
||||||
decentralized_factor INT := 0;
|
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get service data
|
-- Get service data
|
||||||
@@ -46,20 +43,22 @@ BEGIN
|
|||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE "id" = service_id;
|
WHERE "id" = service_id;
|
||||||
|
|
||||||
-- Check for onion URLs
|
-- Adjust score based on KYC level clarification modifiers
|
||||||
IF EXISTS (
|
SELECT
|
||||||
SELECT 1 FROM "Service"
|
CASE
|
||||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
|
||||||
) THEN
|
ELSE 0 -- Default modifier when no clarification or unrecognized value
|
||||||
onion_factor := 5;
|
END
|
||||||
END IF;
|
INTO clarification_factor
|
||||||
|
FROM "Service"
|
||||||
|
WHERE "id" = service_id;
|
||||||
|
|
||||||
-- Check for i2p URLs
|
-- Check for onion or i2p URLs
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM "Service"
|
SELECT 1 FROM "Service"
|
||||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||||
) THEN
|
) THEN
|
||||||
i2p_factor := 5;
|
onion_or_i2p_factor := 5;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
-- Check for Monero acceptance
|
-- Check for Monero acceptance
|
||||||
@@ -75,10 +74,10 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
WHERE sa."serviceId" = service_id;
|
||||||
|
|
||||||
-- Calculate final privacy score (base 100)
|
-- Calculate final privacy score (base 100)
|
||||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
|
||||||
|
|
||||||
-- Ensure the score is in reasonable bounds (0-100)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||||
@@ -91,9 +90,13 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||||
RETURNS INT AS $$
|
RETURNS INT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
trust_score INT := 50; -- Start from middle value (50)
|
trust_score INT := 0;
|
||||||
verification_factor INT;
|
verification_factor INT;
|
||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
|
recently_approved_factor INT := 0;
|
||||||
|
tos_penalty_factor INT := 0;
|
||||||
|
operating_since_factor INT := 0;
|
||||||
|
legally_registered_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -113,32 +116,63 @@ BEGIN
|
|||||||
INTO attributes_score
|
INTO attributes_score
|
||||||
FROM "ServiceAttribute" sa
|
FROM "ServiceAttribute" sa
|
||||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
WHERE sa."serviceId" = service_id;
|
||||||
|
|
||||||
-- Apply penalty if service was listed within the last 15 days
|
-- Apply penalty if service was approved within the last 15 days
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM "Service"
|
FROM "Service"
|
||||||
WHERE id = service_id
|
WHERE id = service_id
|
||||||
AND "listedAt" IS NOT NULL
|
AND "approvedAt" IS NOT NULL
|
||||||
AND "verificationStatus" = 'APPROVED'
|
AND "verificationStatus" = 'APPROVED'
|
||||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||||
) THEN
|
) THEN
|
||||||
trust_score := trust_score - 10;
|
recently_approved_factor := -10;
|
||||||
-- Update the isRecentlyListed flag to true
|
-- Update the isRecentlyApproved flag to true
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = TRUE
|
SET "isRecentlyApproved" = TRUE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
ELSE
|
ELSE
|
||||||
-- Update the isRecentlyListed flag to false
|
-- Update the isRecentlyApproved flag to false
|
||||||
UPDATE "Service"
|
UPDATE "Service"
|
||||||
SET "isRecentlyListed" = FALSE
|
SET "isRecentlyApproved" = FALSE
|
||||||
WHERE id = service_id;
|
WHERE id = service_id;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- Apply penalty if ToS cannot be analyzed
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Service"
|
||||||
|
WHERE id = service_id
|
||||||
|
AND "tosReviewAt" IS NOT NULL
|
||||||
|
AND "tosReview" IS NULL
|
||||||
|
) THEN
|
||||||
|
tos_penalty_factor := -3;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determine trust adjustment based on operatingSince
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN "operatingSince" IS NULL THEN 0
|
||||||
|
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
|
||||||
|
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
INTO operating_since_factor
|
||||||
|
FROM "Service"
|
||||||
|
WHERE id = service_id;
|
||||||
|
|
||||||
|
-- Check for legal registration (country code or company name)
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM "Service"
|
||||||
|
WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL)
|
||||||
|
) THEN
|
||||||
|
legally_registered_factor := 2;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Calculate final trust score (base 100)
|
-- Calculate final trust score (base 100)
|
||||||
trust_score := trust_score + verification_factor + attributes_score;
|
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor + legally_registered_factor;
|
||||||
|
|
||||||
-- Ensure the score is in reasonable bounds (0-100)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||||
|
|
||||||
@@ -152,7 +186,7 @@ RETURNS INT AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
overall_score INT;
|
overall_score INT;
|
||||||
BEGIN
|
BEGIN
|
||||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
|
||||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
@@ -1,48 +1,60 @@
|
|||||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
|
||||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
|
||||||
-- are set or cleared appropriately when a service's verification status is updated.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Manage listedAt timestamp
|
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
|
||||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
|
||||||
IF OLD."listedAt" IS NULL THEN
|
IF OLD."listedAt" IS NULL THEN
|
||||||
NEW."listedAt" := NOW();
|
NEW."listedAt" := NOW();
|
||||||
NEW."isRecentlyListed" := TRUE;
|
|
||||||
END IF;
|
END IF;
|
||||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
ELSE
|
||||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
|
||||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
|
||||||
NEW."listedAt" := NULL;
|
NEW."listedAt" := NULL;
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Manage verifiedAt timestamp
|
|
||||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
|
||||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
|
||||||
NEW."verifiedAt" := NOW();
|
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
|
||||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
|
||||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
|
||||||
NEW."verifiedAt" := NULL;
|
|
||||||
NEW."isRecentlyListed" := FALSE;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
-- Drop the old trigger first if it exists under the old name
|
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
|
||||||
|
IF OLD."approvedAt" IS NULL THEN
|
||||||
|
NEW."approvedAt" := NOW();
|
||||||
|
NEW."isRecentlyApproved" := TRUE;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
NEW."approvedAt" := NULL;
|
||||||
|
NEW."isRecentlyApproved" := FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||||
|
NEW."verifiedAt" := NOW();
|
||||||
|
ELSE
|
||||||
|
NEW."verifiedAt" := NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
|
||||||
|
NEW."spamAt" := NOW();
|
||||||
|
ELSE
|
||||||
|
NEW."spamAt" := NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Drop the old triggers TODO: remove this some day
|
||||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||||
-- Drop the trigger if it exists under the new name
|
|
||||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||||
|
|
||||||
CREATE TRIGGER trigger_manage_service_timestamps
|
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
|
||||||
|
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_manage_service_visibility_timestamps
|
||||||
|
BEFORE UPDATE OF "serviceVisibility" ON "Service"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION manage_service_visibility_timestamps();
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_manage_service_verification_timestamps
|
||||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
-- Only execute the function if the verificationStatus value has actually changed
|
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
|
||||||
EXECUTE FUNCTION manage_service_timestamps();
|
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||||
BEGIN
|
BEGIN
|
||||||
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
|
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
|
||||||
|
-- Notify all admins when a new suggestion is created
|
||||||
|
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
|
||||||
|
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
|
||||||
|
FROM "User" u
|
||||||
|
WHERE u."admin" = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "Notification" n
|
||||||
|
WHERE n."userId" = u."id"
|
||||||
|
AND n."type" = 'SUGGESTION_CREATED'
|
||||||
|
AND n."aboutServiceSuggestionId" = NEW."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||||
-- Notify suggestion author (if not the sender)
|
-- Notify suggestion author (if not the sender)
|
||||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||||
@@ -55,6 +68,13 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger for new suggestions
|
||||||
|
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
|
||||||
|
CREATE TRIGGER service_suggestion_created_notifications_trigger
|
||||||
|
AFTER INSERT ON "ServiceSuggestion"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||||
|
|
||||||
-- Trigger for new messages
|
-- Trigger for new messages
|
||||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||||
|
|||||||
8
web/public/favicon-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#3BDB78" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 619 B |
8
web/public/favicon-dev-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#FF0040" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
8
web/public/favicon-dev-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#FF0040" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 613 B |
7
web/public/favicon-dev-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 607 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
8
web/public/favicon-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#33BE00" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 616 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 607 B |
8
web/public/favicon-stage-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
8
web/public/favicon-stage-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||||
|
<title>KYCnot.me logo with badge</title>
|
||||||
|
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||||
|
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||||
|
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||||
|
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 623 B |
7
web/public/favicon-stage-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
|
||||||
|
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||||
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 639 B |
@@ -1,13 +1,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
<style>
|
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
|
||||||
@media (prefers-color-scheme: light) {
|
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||||
path {
|
|
||||||
fill: #0080ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<path fill="#00ffff" fill-rule="evenodd"
|
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 642 B |
@@ -1,5 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||||
<title>KYCnot.me logo</title>
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
clip-rule="evenodd" />
|
clip-rule="evenodd" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
6
web/public/notification-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||||
|
<title>KYCnot.me logo</title>
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 566 B |
@@ -1,83 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
|
|
||||||
/// <reference lib="webworker" />
|
|
||||||
|
|
||||||
/** @type {ServiceWorkerGlobalScope} */
|
|
||||||
// @ts-expect-error
|
|
||||||
const typedSelf = self
|
|
||||||
|
|
||||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
|
|
||||||
|
|
||||||
typedSelf.addEventListener('install', (event) => {
|
|
||||||
console.log('Service Worker installing')
|
|
||||||
typedSelf.skipWaiting()
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('activate', (event) => {
|
|
||||||
console.log('Service Worker activating')
|
|
||||||
event.waitUntil(typedSelf.clients.claim())
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('push', (event) => {
|
|
||||||
console.log('Push event received:', event)
|
|
||||||
|
|
||||||
if (!event.data) {
|
|
||||||
console.log('Push event but no data')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let notificationData
|
|
||||||
try {
|
|
||||||
notificationData = event.data.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing push data:', error)
|
|
||||||
notificationData = {
|
|
||||||
title: 'New Notification',
|
|
||||||
options: {
|
|
||||||
body: event.data.text() || 'You have a new notification',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title, options } = notificationData
|
|
||||||
|
|
||||||
const notificationOptions = {
|
|
||||||
body: options.body || '',
|
|
||||||
icon: options.icon || '/favicon.svg',
|
|
||||||
badge: options.badge || '/favicon.svg',
|
|
||||||
data: options.data || {},
|
|
||||||
requireInteraction: false,
|
|
||||||
silent: false,
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
|
|
||||||
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('notificationclick', (event) => {
|
|
||||||
console.log('Notification clicked:', event)
|
|
||||||
|
|
||||||
event.notification.close()
|
|
||||||
|
|
||||||
const url = event.notification.data?.url || '/'
|
|
||||||
|
|
||||||
event.waitUntil(
|
|
||||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
|
||||||
// If a window is already open, focus it
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url === url && 'focus' in client) {
|
|
||||||
return client.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, open a new window
|
|
||||||
if (typedSelf.clients.openWindow) {
|
|
||||||
return typedSelf.clients.openWindow(url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
typedSelf.addEventListener('notificationclose', (event) => {
|
|
||||||
console.log('Notification closed:', event)
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
|
import { pick } from 'lodash-es'
|
||||||
|
|
||||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||||
import { createAccount } from '../lib/accountCreate'
|
import { createAccount } from '../lib/accountCreate'
|
||||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
|||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { startImpersonating } from '../lib/impersonation'
|
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
|||||||
return { user }
|
return { user }
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
delete: defineProtectedAction({
|
||||||
|
accept: 'form',
|
||||||
|
permissions: 'user',
|
||||||
|
input: z
|
||||||
|
.object({
|
||||||
|
...captchaFormSchemaProperties,
|
||||||
|
})
|
||||||
|
.superRefine(captchaFormSchemaSuperRefine),
|
||||||
|
handler: async (_input, context) => {
|
||||||
|
if (context.locals.user.admin || context.locals.user.moderator) {
|
||||||
|
throw new ActionError({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'Admins and moderators cannot delete their own accounts.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: context.locals.user.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
|
||||||
|
|
||||||
|
if (context.locals.actualUser) {
|
||||||
|
await stopImpersonating(context)
|
||||||
|
} else {
|
||||||
|
await logout(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deletedUser }
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,33 @@
|
|||||||
import { z } from 'astro/zod'
|
import { z } from 'astro/zod'
|
||||||
import { sumBy } from 'lodash-es'
|
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { sendPushNotification } from '../../lib/webPush'
|
|
||||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||||
|
|
||||||
export const adminNotificationActions = {
|
export const adminNotificationActions = {
|
||||||
webPush: {
|
test: defineProtectedAction({
|
||||||
test: defineProtectedAction({
|
accept: 'form',
|
||||||
accept: 'form',
|
permissions: 'admin',
|
||||||
permissions: 'admin',
|
input: z.object({
|
||||||
input: z.object({
|
userNames: stringListOfSlugsSchemaRequired,
|
||||||
userNames: stringListOfSlugsSchemaRequired,
|
|
||||||
title: z.string().min(1).nullable(),
|
|
||||||
body: z.string().nullable(),
|
|
||||||
url: z.string().url().optional(),
|
|
||||||
}),
|
|
||||||
handler: async (input) => {
|
|
||||||
const subscriptions = await prisma.pushSubscription.findMany({
|
|
||||||
where: { user: { name: { in: input.userNames } } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
endpoint: true,
|
|
||||||
p256dh: true,
|
|
||||||
auth: true,
|
|
||||||
userAgent: true,
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
subscriptions.map(async (subscription) => {
|
|
||||||
const result = await sendPushNotification(
|
|
||||||
{
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
keys: {
|
|
||||||
p256dh: subscription.p256dh,
|
|
||||||
auth: subscription.auth,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: input.title ?? 'Test Notification',
|
|
||||||
body: input.body ?? 'This is a test push notification from KYCNot.me',
|
|
||||||
url: input.url ?? '/',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// If subscription is invalid, remove it from database
|
|
||||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
|
||||||
await prisma.pushSubscription.delete({
|
|
||||||
where: { id: subscription.id },
|
|
||||||
})
|
|
||||||
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.success
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
|
|
||||||
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
|
|
||||||
const now = new Date()
|
|
||||||
return {
|
|
||||||
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
|
|
||||||
totalSubscriptions: subscriptions.length,
|
|
||||||
successCount,
|
|
||||||
failureCount,
|
|
||||||
sentAt: now,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
},
|
handler: async (input) => {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { name: { in: input.userNames } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const notifications = await prisma.notification.createManyAndReturn({
|
||||||
|
data: users.map((user) => ({
|
||||||
|
type: 'TEST',
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Created ${notifications.length.toString()} notifications.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { ActionError } from 'astro:actions'
|
|||||||
import { uniq } from 'lodash-es'
|
import { uniq } from 'lodash-es'
|
||||||
import slugify from 'slugify'
|
import slugify from 'slugify'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../../constants/countries'
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||||
import {
|
import {
|
||||||
imageFileSchema,
|
imageFileSchema,
|
||||||
|
stringListOfContactMethodsSchema,
|
||||||
stringListOfUrlsSchemaRequired,
|
stringListOfUrlsSchemaRequired,
|
||||||
zodCohercedNumber,
|
zodCohercedNumber,
|
||||||
zodContactMethod,
|
zodContactMethod,
|
||||||
@@ -44,6 +46,7 @@ const serviceSchemaBase = z.object({
|
|||||||
description: z.string().min(1),
|
description: z.string().min(1),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
|
contactMethods: stringListOfContactMethodsSchema,
|
||||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||||
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
||||||
attributes: z.array(z.coerce.number().int().positive()),
|
attributes: z.array(z.coerce.number().int().positive()),
|
||||||
@@ -51,17 +54,27 @@ const serviceSchemaBase = z.object({
|
|||||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||||
verificationSummary: z.string().optional().nullable().default(null),
|
verificationSummary: z.string().optional().nullable().default(null),
|
||||||
verificationProofMd: z.string().optional().nullable().default(null),
|
verificationProofMd: z.string().optional().nullable().default(null),
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||||
referral: z
|
referral: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||||
.optional()
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null),
|
.default(null),
|
||||||
|
operatingSince: z.coerce.date().optional().nullable(),
|
||||||
|
registrationCountryCode: z
|
||||||
|
.union([countriesZodEnumById, z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||||
|
message: 'Country code must be a valid 2-character code or empty',
|
||||||
|
}),
|
||||||
|
registeredCompanyName: z.string().trim().max(100).optional().nullable(),
|
||||||
imageFile: imageFileSchema,
|
imageFile: imageFileSchema,
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
internalNote: z.string().optional(),
|
internalNote: z.string().optional(),
|
||||||
|
strictCommentingEnabled: z.boolean().optional().default(false),
|
||||||
|
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Define schema for the create action input
|
// Define schema for the create action input
|
||||||
@@ -126,10 +139,12 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
referral: input.referral,
|
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||||
|
commentSectionMessage: input.commentSectionMessage,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -140,6 +155,11 @@ export const adminServiceActions = {
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
contactMethods: {
|
||||||
|
create: input.contactMethods.map((value) => ({
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
imageUrl,
|
imageUrl,
|
||||||
internalNotes: input.internalNote
|
internalNotes: input.internalNote
|
||||||
? {
|
? {
|
||||||
@@ -149,6 +169,9 @@ export const adminServiceActions = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -244,10 +267,12 @@ export const adminServiceActions = {
|
|||||||
verificationSummary: input.verificationSummary,
|
verificationSummary: input.verificationSummary,
|
||||||
verificationProofMd: input.verificationProofMd,
|
verificationProofMd: input.verificationProofMd,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
referral: input.referral,
|
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||||
|
commentSectionMessage: input.commentSectionMessage,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
referral: input.referral || null,
|
||||||
serviceVisibility: input.serviceVisibility,
|
serviceVisibility: input.serviceVisibility,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
overallScore: input.overallScore,
|
|
||||||
previousSlugs:
|
previousSlugs:
|
||||||
existingService.slug !== input.slug
|
existingService.slug !== input.slug
|
||||||
? {
|
? {
|
||||||
@@ -256,7 +281,6 @@ export const adminServiceActions = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories: {
|
categories: {
|
||||||
connect: categoriesToAdd.map((id) => ({ id })),
|
connect: categoriesToAdd.map((id) => ({ id })),
|
||||||
@@ -273,6 +297,9 @@ export const adminServiceActions = {
|
|||||||
attributeId,
|
attributeId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -307,7 +334,7 @@ export const adminServiceActions = {
|
|||||||
input: z.object({
|
input: z.object({
|
||||||
id: z.number().int().positive(),
|
id: z.number().int().positive(),
|
||||||
label: z.string().min(1).max(50).nullable(),
|
label: z.string().min(1).max(50).nullable(),
|
||||||
value: z.string().url(),
|
value: zodContactMethod,
|
||||||
serviceId: z.number().int().positive(),
|
serviceId: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
@@ -333,7 +360,6 @@ export const adminServiceActions = {
|
|||||||
await prisma.serviceContactMethod.delete({
|
await prisma.serviceContactMethod.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
})
|
||||||
return { success: true }
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -458,9 +484,7 @@ export const adminServiceActions = {
|
|||||||
permissions: 'admin',
|
permissions: 'admin',
|
||||||
input: evidenceImageDeleteSchema,
|
input: evidenceImageDeleteSchema,
|
||||||
handler: async (input) => {
|
handler: async (input) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
await deleteFileLocally(input.fileUrl)
|
await deleteFileLocally(input.fileUrl)
|
||||||
return { success: true }
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
import { type Prisma, type ServiceUserRole } from '@prisma/client'
|
||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../../lib/fileStorage'
|
import { saveFileLocally } from '../../lib/fileStorage'
|
||||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
import { prisma } from '../../lib/prisma'
|
||||||
|
|
||||||
const prisma = prismaInstance as PrismaClient
|
|
||||||
|
|
||||||
const selectUserReturnFields = {
|
const selectUserReturnFields = {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
|||||||
tosUrls: true,
|
tosUrls: true,
|
||||||
referral: true,
|
referral: true,
|
||||||
listedAt: true,
|
listedAt: true,
|
||||||
|
approvedAt: true,
|
||||||
verifiedAt: true,
|
verifiedAt: true,
|
||||||
serviceVisibility: true,
|
serviceVisibility: true,
|
||||||
} as const satisfies Prisma.ServiceSelect
|
} as const satisfies Prisma.ServiceSelect
|
||||||
|
|
||||||
let service = await prisma.service.findFirst({
|
let service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
OR: [
|
OR: [
|
||||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
|||||||
if (!service && input.slug) {
|
if (!service && input.slug) {
|
||||||
service = await prisma.service.findFirst({
|
service = await prisma.service.findFirst({
|
||||||
where: {
|
where: {
|
||||||
listedAt: { lte: new Date() },
|
|
||||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
previousSlugs: { has: input.slug },
|
previousSlugs: { has: input.slug },
|
||||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
|||||||
!service ||
|
!service ||
|
||||||
(service.serviceVisibility !== 'PUBLIC' &&
|
(service.serviceVisibility !== 'PUBLIC' &&
|
||||||
service.serviceVisibility !== 'ARCHIVED' &&
|
service.serviceVisibility !== 'ARCHIVED' &&
|
||||||
service.serviceVisibility !== 'UNLISTED') ||
|
service.serviceVisibility !== 'UNLISTED')
|
||||||
!service.listedAt ||
|
|
||||||
service.listedAt > new Date()
|
|
||||||
) {
|
) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
@@ -130,12 +127,13 @@ export const apiServiceActions = {
|
|||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
verifiedAt: service.verifiedAt,
|
verifiedAt: service.verifiedAt,
|
||||||
|
approvedAt: service.approvedAt,
|
||||||
kycLevel: service.kycLevel,
|
kycLevel: service.kycLevel,
|
||||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
kycLevelClarification: service.kycLevelClarification,
|
kycLevelClarification: service.kycLevelClarification,
|
||||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||||
'value',
|
'value',
|
||||||
'name',
|
'label',
|
||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
categories: service.categories,
|
categories: service.categories,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { CommentStatus, Prisma } from '@prisma/client'
|
|||||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
||||||
const MAX_COMMENTS_PER_WINDOW = 1
|
const MAX_COMMENTS_PER_WINDOW = 1
|
||||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
||||||
|
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
|
||||||
|
|
||||||
export const commentActions = {
|
export const commentActions = {
|
||||||
vote: defineProtectedAction({
|
vote: defineProtectedAction({
|
||||||
@@ -103,7 +104,7 @@ export const commentActions = {
|
|||||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||||
issueScam: z.coerce.boolean().optional(),
|
issueScam: z.coerce.boolean().optional(),
|
||||||
issueDetails: z.string().max(120).optional(),
|
issueDetails: z.string().max(120).optional(),
|
||||||
orderId: z.string().max(100).optional(),
|
orderId: z.string().max(COMMENT_ORDER_ID_MAX_LENGTH).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.rating && data.parentId) {
|
if (data.rating && data.parentId) {
|
||||||
@@ -270,6 +271,18 @@ export const commentActions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRelatedToService = !!(await tx.serviceUser.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_serviceId: {
|
||||||
|
userId: context.locals.user.id,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
// Prepare data object with proper type safety
|
// Prepare data object with proper type safety
|
||||||
const commentData: Prisma.CommentCreateInput = {
|
const commentData: Prisma.CommentCreateInput = {
|
||||||
content: input.content,
|
content: input.content,
|
||||||
@@ -277,7 +290,12 @@ export const commentActions = {
|
|||||||
author: { connect: { id: context.locals.user.id } },
|
author: { connect: { id: context.locals.user.id } },
|
||||||
|
|
||||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
status:
|
||||||
|
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
|
||||||
|
? 'APPROVED'
|
||||||
|
: isIssueReport
|
||||||
|
? 'HUMAN_PENDING'
|
||||||
|
: 'PENDING',
|
||||||
requiresAdminReview,
|
requiresAdminReview,
|
||||||
orderId: input.orderId?.trim() ?? null,
|
orderId: input.orderId?.trim() ?? null,
|
||||||
kycRequested: input.issueKycRequested === true,
|
kycRequested: input.issueKycRequested === true,
|
||||||
@@ -345,10 +363,11 @@ export const commentActions = {
|
|||||||
'order-id-status',
|
'order-id-status',
|
||||||
'kyc-requested',
|
'kyc-requested',
|
||||||
'funds-blocked',
|
'funds-blocked',
|
||||||
|
'toggle-rating-active',
|
||||||
]),
|
]),
|
||||||
value: z.union([
|
value: z.union([
|
||||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
|
||||||
z.boolean(),
|
z.boolean(),
|
||||||
z.string(),
|
z.string(),
|
||||||
]),
|
]),
|
||||||
@@ -411,7 +430,7 @@ export const commentActions = {
|
|||||||
updateData.privateContext = input.value as string
|
updateData.privateContext = input.value as string
|
||||||
break
|
break
|
||||||
case 'order-id-status':
|
case 'order-id-status':
|
||||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
|
||||||
break
|
break
|
||||||
case 'kyc-requested':
|
case 'kyc-requested':
|
||||||
updateData.kycRequested = !!input.value
|
updateData.kycRequested = !!input.value
|
||||||
@@ -419,6 +438,9 @@ export const commentActions = {
|
|||||||
case 'funds-blocked':
|
case 'funds-blocked':
|
||||||
updateData.fundsBlocked = !!input.value
|
updateData.fundsBlocked = !!input.value
|
||||||
break
|
break
|
||||||
|
case 'toggle-rating-active':
|
||||||
|
updateData.ratingActive = !!input.value
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the comment
|
// Update the comment
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const notificationActions = {
|
|||||||
endpoint: z.string(),
|
endpoint: z.string(),
|
||||||
p256dhKey: z.string(),
|
p256dhKey: z.string(),
|
||||||
authKey: z.string(),
|
authKey: z.string(),
|
||||||
userAgent: z.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
await prisma.pushSubscription.upsert({
|
await prisma.pushSubscription.upsert({
|
||||||
@@ -43,14 +42,12 @@ export const notificationActions = {
|
|||||||
update: {
|
update: {
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
p256dh: input.p256dhKey,
|
p256dh: input.p256dhKey,
|
||||||
auth: input.authKey,
|
auth: input.authKey,
|
||||||
userAgent: input.userAgent,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -58,25 +55,17 @@ export const notificationActions = {
|
|||||||
|
|
||||||
unsubscribe: defineProtectedAction({
|
unsubscribe: defineProtectedAction({
|
||||||
accept: 'json',
|
accept: 'json',
|
||||||
permissions: 'user',
|
permissions: 'guest',
|
||||||
input: z.object({
|
input: z.object({
|
||||||
endpoint: z.string().optional(),
|
endpoint: z.string(),
|
||||||
}),
|
}),
|
||||||
handler: async (input, context) => {
|
handler: async (input, context) => {
|
||||||
if (input.endpoint) {
|
await prisma.pushSubscription.delete({
|
||||||
await prisma.pushSubscription.deleteMany({
|
where: {
|
||||||
where: {
|
userId: context.locals.user?.id ?? undefined,
|
||||||
userId: context.locals.user.id,
|
endpoint: input.endpoint,
|
||||||
endpoint: input.endpoint,
|
},
|
||||||
},
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await prisma.pushSubscription.deleteMany({
|
|
||||||
where: {
|
|
||||||
userId: context.locals.user.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'astro/zod'
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { formatDistanceStrict } from 'date-fns'
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
|
|
||||||
|
import { countriesZodEnumById } from '../constants/countries'
|
||||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
@@ -36,6 +37,9 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
id: {
|
id: {
|
||||||
in: matches.map(({ id }) => id),
|
in: matches.map(({ id }) => id),
|
||||||
},
|
},
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -46,31 +50,51 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
const serializeExtraNotes = async <T extends Record<string, unknown>, NK extends keyof T>(
|
||||||
input: T,
|
input: T,
|
||||||
skipKeys: (keyof T)[] = []
|
skipKeys: NK[] = [],
|
||||||
): string => {
|
mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
|
||||||
return Object.entries(input)
|
): Promise<string> => {
|
||||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
return (
|
||||||
.map(([key, value]) => {
|
await Promise.all(
|
||||||
let serializedValue = ''
|
Object.entries(input)
|
||||||
if (typeof value === 'string') {
|
.filter(
|
||||||
serializedValue = value
|
(
|
||||||
} else if (value === undefined || value === null) {
|
entry
|
||||||
serializedValue = ''
|
): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
|
||||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
!skipKeys.some((k) => k === entry[0])
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
)
|
||||||
serializedValue = value.toString()
|
.map(async ([key, originalValue]) => {
|
||||||
} else {
|
const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
|
||||||
try {
|
let serializedValue = ''
|
||||||
serializedValue = JSON.stringify(value)
|
if (typeof value === 'string') {
|
||||||
} catch (error) {
|
serializedValue = value
|
||||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
} else if (value === undefined || value === null) {
|
||||||
}
|
serializedValue = ''
|
||||||
}
|
} else if (Array.isArray(value)) {
|
||||||
return `- ${key}: ${serializedValue}`
|
serializedValue = value.map((item) => String(item)).join(', ')
|
||||||
})
|
} else if (typeof value === 'object' && value instanceof Date) {
|
||||||
.join('\n')
|
serializedValue = value.toISOString()
|
||||||
|
} else if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
'toString' in value &&
|
||||||
|
typeof value.toString === 'function' &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
|
value.toString() !== '[object Object]'
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
|
serializedValue = value.toString()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
serializedValue = JSON.stringify(value)
|
||||||
|
} catch (error) {
|
||||||
|
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `- ${key}: ${serializedValue}`
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serviceSuggestionActions = {
|
export const serviceSuggestionActions = {
|
||||||
@@ -144,17 +168,7 @@ export const serviceSuggestionActions = {
|
|||||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||||
.regex(/^[a-z0-9-]+$/, {
|
.regex(/^[a-z0-9-]+$/, {
|
||||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||||
})
|
}),
|
||||||
.refine(
|
|
||||||
async (slug) => {
|
|
||||||
const exists = await prisma.service.findUnique({
|
|
||||||
select: { id: true },
|
|
||||||
where: { slug },
|
|
||||||
})
|
|
||||||
return !exists
|
|
||||||
},
|
|
||||||
{ message: 'Slug must be unique, try a different one' }
|
|
||||||
),
|
|
||||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||||
tosUrls: stringListOfUrlsSchemaRequired,
|
tosUrls: stringListOfUrlsSchemaRequired,
|
||||||
@@ -165,6 +179,20 @@ export const serviceSuggestionActions = {
|
|||||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||||
imageFile: imageFileSchemaRequired,
|
imageFile: imageFileSchemaRequired,
|
||||||
|
rulesConfirm: z.literal('on', {
|
||||||
|
errorMap: () => ({
|
||||||
|
message: 'You must accept the suggestion rules and process to continue',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
operatingSince: z.coerce.date().optional(),
|
||||||
|
registrationCountryCode: z
|
||||||
|
.union([countriesZodEnumById, z.literal('')])
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||||
|
message: 'Country code must be a valid 2-character code or empty',
|
||||||
|
}),
|
||||||
|
registeredCompanyName: z.string().trim().max(100).optional(),
|
||||||
/** @deprecated Honey pot field, do not use */
|
/** @deprecated Honey pot field, do not use */
|
||||||
message: z.unknown().optional(),
|
message: z.unknown().optional(),
|
||||||
skipDuplicateCheck: z
|
skipDuplicateCheck: z
|
||||||
@@ -184,24 +212,63 @@ export const serviceSuggestionActions = {
|
|||||||
location: 'serviceSuggestion.createService',
|
location: 'serviceSuggestion.createService',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const serviceWithSameSlug = await prisma.service.findUnique({
|
||||||
|
select: { id: true, name: true, slug: true, description: true },
|
||||||
|
where: { slug: input.slug },
|
||||||
|
})
|
||||||
|
|
||||||
if (!input.skipDuplicateCheck) {
|
if (!input.skipDuplicateCheck) {
|
||||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
const possibleDuplicates = [
|
||||||
|
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
||||||
|
...(await findPossibleDuplicates(input)),
|
||||||
|
]
|
||||||
|
|
||||||
if (possibleDuplicates.length > 0) {
|
if (possibleDuplicates.length > 0) {
|
||||||
return {
|
return {
|
||||||
hasDuplicates: true,
|
hasDuplicates: true,
|
||||||
possibleDuplicates,
|
possibleDuplicates,
|
||||||
extraNotes: serializeExtraNotes(input, [
|
extraNotes: await serializeExtraNotes(
|
||||||
'skipDuplicateCheck',
|
input,
|
||||||
'message',
|
[
|
||||||
'imageFile',
|
'skipDuplicateCheck',
|
||||||
'captcha-value',
|
'message',
|
||||||
'captcha-solution-hash',
|
'imageFile',
|
||||||
]),
|
'captcha-value',
|
||||||
|
'captcha-solution-hash',
|
||||||
|
'rulesConfirm',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
attributes: async (value) => {
|
||||||
|
const dbAttributes = await prisma.attribute.findMany({
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
where: { id: { in: value } },
|
||||||
|
})
|
||||||
|
return dbAttributes.map((attribute) => `\n - ${attribute.title}`).join('')
|
||||||
|
},
|
||||||
|
categories: async (value) => {
|
||||||
|
const dbCategories = await prisma.category.findMany({
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
where: { id: { in: value } },
|
||||||
|
})
|
||||||
|
return dbCategories.map((category) => category.name)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
serviceSuggestion: undefined,
|
serviceSuggestion: undefined,
|
||||||
service: undefined,
|
service: undefined,
|
||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (serviceWithSameSlug) {
|
||||||
|
throw new ActionError({
|
||||||
|
message: 'Slug already in use, try a different one',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||||
@@ -223,6 +290,9 @@ export const serviceSuggestionActions = {
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
|
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||||
|
registeredCompanyName: input.registeredCompanyName,
|
||||||
serviceUrls,
|
serviceUrls,
|
||||||
tosUrls: input.tosUrls,
|
tosUrls: input.tosUrls,
|
||||||
onionUrls,
|
onionUrls,
|
||||||
@@ -235,7 +305,6 @@ export const serviceSuggestionActions = {
|
|||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
listedAt: new Date(),
|
|
||||||
serviceVisibility: 'UNLISTED',
|
serviceVisibility: 'UNLISTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
8
web/src/assets/review-badge/long-black.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||||
|
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||||
|
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#BEBEBE"
|
||||||
|
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#3BDB78"
|
||||||
|
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
8
web/src/assets/review-badge/long-white.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||||
|
<rect width="431" height="63" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||||
|
<path fill="#28AE5B" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#3F3F3F"
|
||||||
|
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#28AE5B"
|
||||||
|
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-black.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||||
|
<rect width="255" height="95" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||||
|
<path fill="#3BDB78" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#BEBEBE"
|
||||||
|
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#3BDB78"
|
||||||
|
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
8
web/src/assets/review-badge/short-white.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="48" width="128" viewBox="0 0 256 96">
|
||||||
|
<rect width="255" height="95" x=".5" y=".5" fill="#fff" stroke="#ECF0EE" rx="7.5" />
|
||||||
|
<path fill="#28AE5B" d="m56.5 16 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||||
|
<path fill="#3F3F3F"
|
||||||
|
d="M82.7 40V20.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V30h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V40h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3Zm13.6 9.2c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1h-11.7v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L122.3 40h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V25.3h2.8V40h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H138v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L168 40h-2.9l-3-10.6h-.2L159 40h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V40h-2.9V25.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V40H208v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||||
|
<path fill="#28AE5B"
|
||||||
|
d="M27 52a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V53a1 1 0 0 0-1-1H27Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V57a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V75a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L55 57.5a1 1 0 0 1 .8-1.5ZM83 56h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H82v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H83a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V61a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V59.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V60h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V60h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V63.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V75a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V57a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L194 71.2l-6.7-14.6a1 1 0 0 0-1-.6H183Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
24
web/src/components/AdminNavigationFixScript.astro
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
if (!Astro.locals.user?.admin) return
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
|
||||||
|
// This is a workaround to prevent the client router messing up inputs. //
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
document.addEventListener('astro:page-load', () => {
|
||||||
|
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
|
||||||
|
const isAdminPage = window.location.pathname.startsWith('/admin')
|
||||||
|
if (isAdminPage) {
|
||||||
|
element.setAttribute('data-astro-reload', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = element.href ? new URL(element.href) : null
|
||||||
|
if (url?.pathname.startsWith('/admin')) {
|
||||||
|
element.setAttribute('data-astro-reload', '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -6,20 +6,24 @@ import { cn } from '../lib/cn'
|
|||||||
|
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
import type { O } from 'ts-toolbelt'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'div'> & {
|
type Props = HTMLAttributes<'div'> & {
|
||||||
announcement: Prisma.AnnouncementGetPayload<{
|
announcement: O.Optional<
|
||||||
select: {
|
Prisma.AnnouncementGetPayload<{
|
||||||
id: true
|
select: {
|
||||||
content: true
|
id: true
|
||||||
type: true
|
content: true
|
||||||
link: true
|
type: true
|
||||||
linkText: true
|
link: true
|
||||||
startDate: true
|
linkText: true
|
||||||
endDate: true
|
startDate: true
|
||||||
isActive: true
|
endDate: true
|
||||||
}
|
isActive: true
|
||||||
}>
|
}
|
||||||
|
}>,
|
||||||
|
'link' | 'linkText'
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
const { announcement, class: className, ...props } = Astro.props
|
const { announcement, class: className, ...props } = Astro.props
|
||||||
@@ -31,12 +35,15 @@ const Tag = announcement.link ? 'a' : 'div'
|
|||||||
|
|
||||||
<Tag
|
<Tag
|
||||||
href={announcement.link}
|
href={announcement.link}
|
||||||
target={announcement.link ? '_blank' : undefined}
|
target={announcement.link && new URL(announcement.link, Astro.url.origin).origin !== Astro.url.origin
|
||||||
|
? '_blank'
|
||||||
|
: undefined}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class={cn(
|
class={cn(
|
||||||
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
aria-label="Announcement banner"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -78,15 +85,15 @@ const Tag = announcement.link ? 'a' : 'div'
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{
|
||||||
class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]"
|
!!announcement.link && !!announcement.linkText && (
|
||||||
>
|
<div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]">
|
||||||
<span class="2xs:inline-block hidden">
|
<span class="2xs:inline-block hidden">{announcement.linkText}</span>
|
||||||
{announcement.linkText}
|
<Icon
|
||||||
</span>
|
name="ri:arrow-right-line"
|
||||||
<Icon
|
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||||
name="ri:arrow-right-line"
|
/>
|
||||||
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
</div>
|
||||||
/>
|
)
|
||||||
</div>
|
}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
---
|
---
|
||||||
import LoadingIndicator from 'astro-loading-indicator/component'
|
import LoadingIndicator from 'astro-loading-indicator/component'
|
||||||
import { Schema } from 'astro-seo-schema'
|
import { Schema } from 'astro-seo-schema'
|
||||||
|
import { ONION_ADDRESS } from 'astro:env/server'
|
||||||
import { ClientRouter } from 'astro:transitions'
|
import { ClientRouter } from 'astro:transitions'
|
||||||
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||||
|
import { pwaInfo } from 'virtual:pwa-info'
|
||||||
|
|
||||||
import { isNotArray } from '../lib/arrays'
|
import { isNotArray } from '../lib/arrays'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
|
|
||||||
|
import AdminNavigationFixScript from './AdminNavigationFixScript.astro'
|
||||||
|
import DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||||
|
import DynamicFavicon from './DynamicFavicon.astro'
|
||||||
import HtmxScript from './HtmxScript.astro'
|
import HtmxScript from './HtmxScript.astro'
|
||||||
|
import NotificationEventsScript from './NotificationEventsScript.astro'
|
||||||
import { makeOgImageUrl } from './OgImage'
|
import { makeOgImageUrl } from './OgImage'
|
||||||
|
import ServerEventsScript from './ServerEventsScript.astro'
|
||||||
|
import ServiceWorkerScript from './ServiceWorkerScript.astro'
|
||||||
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
import TailwindJsPluggin from './TailwindJsPluggin.astro'
|
||||||
|
|
||||||
import type { ComponentProps } from 'astro/types'
|
import type { ComponentProps } from 'astro/types'
|
||||||
@@ -70,46 +79,49 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
|
|||||||
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Favicon -->
|
{/* Primary Meta Tags */}
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
|
|
||||||
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
|
|
||||||
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
|
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<title>{fullTitle}</title>
|
<title>{fullTitle}</title>
|
||||||
<!-- {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} -->
|
{/* canonicalUrl && <link rel="canonical" href={canonicalUrl} /> */}
|
||||||
|
<meta http-equiv="onion-location" content={ONION_ADDRESS} />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
{/* Open Graph / Facebook */}
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content={Astro.url} />
|
<meta property="og:url" content={Astro.url} />
|
||||||
<meta property="og:title" content={fullTitle} />
|
<meta property="og:title" content={fullTitle} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
{!!ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
|
||||||
|
|
||||||
<!-- Twitter -->
|
{/* Twitter */}
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:url" content={Astro.url} />
|
<meta property="twitter:url" content={Astro.url} />
|
||||||
<meta property="twitter:title" content={fullTitle} />
|
<meta property="twitter:title" content={fullTitle} />
|
||||||
<meta property="twitter:description" content={description} />
|
<meta property="twitter:description" content={description} />
|
||||||
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
{!!ogImageUrl && <meta property="twitter:image" content={ogImageUrl} />}
|
||||||
|
|
||||||
<!-- Other -->
|
{/* Other */}
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<meta name="theme-color" content="#040505" />
|
<link rel="sitemap" href="/sitemaps/search.xml" />
|
||||||
|
|
||||||
|
{/* PWA */}
|
||||||
|
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
|
||||||
|
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
|
||||||
|
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
|
||||||
|
|
||||||
|
<DynamicFavicon />
|
||||||
|
|
||||||
<!-- Components -->
|
|
||||||
<ClientRouter />
|
<ClientRouter />
|
||||||
|
<AdminNavigationFixScript />
|
||||||
|
|
||||||
<LoadingIndicator color="green" />
|
<LoadingIndicator color="green" />
|
||||||
<TailwindJsPluggin />
|
<TailwindJsPluggin />
|
||||||
{htmx && <HtmxScript />}
|
{htmx && <HtmxScript />}
|
||||||
|
|
||||||
<!-- JSON-LD Schemas -->
|
{/* JSON-LD Schemas */}
|
||||||
{schemas?.map((item) => <Schema item={item} />)}
|
{schemas?.map((item) => <Schema item={item} />)}
|
||||||
|
|
||||||
<!-- Breadcrumbs -->
|
{/* Breadcrumbs */}
|
||||||
{
|
{
|
||||||
breadcrumbLists.map((breadcrumbList) => (
|
breadcrumbLists.map((breadcrumbList) => (
|
||||||
<Schema
|
<Schema
|
||||||
@@ -131,3 +143,15 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
Astro.locals.user && (
|
||||||
|
<>
|
||||||
|
<ServerEventsScript />
|
||||||
|
<ServiceWorkerScript />
|
||||||
|
<NotificationEventsScript />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<DevToolsMessageScript />
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
|||||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../lib/commentsWithReplies'
|
} from '../lib/commentsWithReplies'
|
||||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { formatDateShort } from '../lib/timeAgo'
|
import { formatDateShort } from '../lib/timeAgo'
|
||||||
|
import { urlDomain } from '../lib/urls'
|
||||||
|
|
||||||
import BadgeSmall from './BadgeSmall.astro'
|
import BadgeSmall from './BadgeSmall.astro'
|
||||||
import CommentModeration from './CommentModeration.astro'
|
import CommentModeration from './CommentModeration.astro'
|
||||||
@@ -32,6 +33,7 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
highlightedCommentId: number | null
|
highlightedCommentId: number | null
|
||||||
serviceSlug: string
|
serviceSlug: string
|
||||||
itemReviewedId: string
|
itemReviewedId: string
|
||||||
|
strictCommentingEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -41,6 +43,7 @@ const {
|
|||||||
highlightedCommentId = null,
|
highlightedCommentId = null,
|
||||||
serviceSlug,
|
serviceSlug,
|
||||||
itemReviewedId,
|
itemReviewedId,
|
||||||
|
strictCommentingEnabled,
|
||||||
class: className,
|
class: className,
|
||||||
...htmlProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
@@ -150,13 +153,13 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
checked={comment.suspicious}
|
checked={comment.suspicious}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
<div class="comment-header flex items-center gap-2 text-sm">
|
||||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||||
<span class="collapse-symbol text-xs"></span>
|
<span class="collapse-symbol text-xs"></span>
|
||||||
<span class="sr-only">Toggle comment visibility</span>
|
<span class="sr-only">Toggle comment visibility</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex min-w-16 items-center gap-1">
|
||||||
<UserBadge
|
<UserBadge
|
||||||
user={comment.author}
|
user={comment.author}
|
||||||
size="md"
|
size="md"
|
||||||
@@ -170,7 +173,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
comment.author.admin || comment.author.moderator
|
comment.author.admin || comment.author.moderator
|
||||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||||
: ''
|
: ''
|
||||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
}${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
|
||||||
>
|
>
|
||||||
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -179,7 +182,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* User badges - more compact but still with text */}
|
{/* User badges - more compact but still with text */}
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
<div class="flex w-min grow flex-wrap items-center gap-1">
|
||||||
{
|
{
|
||||||
comment.author.admin && (
|
comment.author.admin && (
|
||||||
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
<BadgeSmall icon="ri:shield-star-fill" color="green" text="Admin" variant="faded" inlineIcon />
|
||||||
@@ -240,15 +243,17 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
comment.author.serviceAffiliations.map((affiliation) => {
|
comment.author.serviceAffiliations
|
||||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
.filter((affiliation) => affiliation.service.slug === serviceSlug)
|
||||||
return (
|
.map((affiliation) => {
|
||||||
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||||
{roleInfo.label} at
|
return (
|
||||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||||
</BadgeSmall>
|
{roleInfo.label} at
|
||||||
)
|
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||||
})
|
</BadgeSmall>
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,6 +494,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
serviceId={comment.serviceId}
|
serviceId={comment.serviceId}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
|
strictCommentingEnabled={strictCommentingEnabled}
|
||||||
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
class="mt-2 hidden peer-checked/collapse:hidden peer-checked/reply:block"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
privateContext: true
|
privateContext: true
|
||||||
orderId: true
|
orderId: true
|
||||||
orderIdStatus: true
|
orderIdStatus: true
|
||||||
|
rating: true
|
||||||
|
ratingActive: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
<div
|
<div
|
||||||
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
|
||||||
>
|
>
|
||||||
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
|
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.status === 'REJECTED'
|
comment.status === 'REJECTED'
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||||
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
|
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.suspicious
|
|
||||||
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
|
||||||
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
|
||||||
)}
|
|
||||||
data-action="suspicious"
|
|
||||||
data-value={!comment.suspicious}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
data-user-id={user.id}
|
|
||||||
>
|
|
||||||
{comment.suspicious ? 'Not Spam' : 'Spam'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
|
||||||
comment.requiresAdminReview
|
|
||||||
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
|
||||||
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
|
||||||
)}
|
|
||||||
data-action="requires-admin-review"
|
|
||||||
data-value={!comment.requiresAdminReview}
|
|
||||||
data-comment-id={comment.id}
|
|
||||||
data-user-id={user.id}
|
|
||||||
>
|
|
||||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
|
||||||
comment.status === 'VERIFIED'
|
comment.status === 'VERIFIED'
|
||||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||||
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
|
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
|
||||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||||
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="bg-night-500 h-5 w-px"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
|
comment.suspicious
|
||||||
|
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
|
||||||
|
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
|
||||||
|
)}
|
||||||
|
data-action="suspicious"
|
||||||
|
data-value={!comment.suspicious}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
data-user-id={user.id}
|
||||||
|
>
|
||||||
|
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
|
comment.requiresAdminReview
|
||||||
|
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
|
||||||
|
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
|
||||||
|
)}
|
||||||
|
data-action="requires-admin-review"
|
||||||
|
data-value={!comment.requiresAdminReview}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
data-user-id={user.id}
|
||||||
|
>
|
||||||
|
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.kycRequested
|
comment.kycRequested
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||||
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
|
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.fundsBlocked
|
comment.fundsBlocked
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||||
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
|
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="bg-night-500 h-5 w-px"></div>
|
||||||
|
|
||||||
|
{
|
||||||
|
comment.rating && (
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
|
comment.ratingActive
|
||||||
|
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||||
|
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||||
|
)}
|
||||||
|
data-action="toggle-rating-active"
|
||||||
|
data-value={!comment.ratingActive}
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
data-user-id={user.id}
|
||||||
|
>
|
||||||
|
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 space-y-1.5">
|
<div class="mt-2 space-y-1.5">
|
||||||
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.orderIdStatus === 'APPROVED'
|
comment.orderIdStatus === 'APPROVED'
|
||||||
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
? 'border border-green-500/30 bg-green-500/20 text-green-400'
|
||||||
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
|
||||||
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
Approve
|
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.orderIdStatus === 'REJECTED'
|
comment.orderIdStatus === 'REJECTED'
|
||||||
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
? 'border border-red-500/30 bg-red-500/20 text-red-400'
|
||||||
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
|
||||||
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
Reject
|
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Reject</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
comment.orderIdStatus === 'PENDING'
|
comment.orderIdStatus === 'PENDING'
|
||||||
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
|
||||||
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
|
||||||
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
Pending
|
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Pending</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
|
||||||
|
comment.orderIdStatus === 'WITHDRAWN'
|
||||||
|
? 'border-night-400 bg-night-500/50 text-night-300 border'
|
||||||
|
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
|
||||||
|
)}
|
||||||
|
data-action="order-id-status"
|
||||||
|
data-value="WITHDRAWN"
|
||||||
|
data-comment-id={comment.id}
|
||||||
|
data-user-id={user.id}
|
||||||
|
>
|
||||||
|
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
|
||||||
|
<span>Withdrawn</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
action === 'suspicious' ||
|
action === 'suspicious' ||
|
||||||
action === 'requires-admin-review' ||
|
action === 'requires-admin-review' ||
|
||||||
action === 'kyc-requested' ||
|
action === 'kyc-requested' ||
|
||||||
action === 'funds-blocked'
|
action === 'funds-blocked' ||
|
||||||
|
action === 'toggle-rating-active'
|
||||||
? value === 'true'
|
? value === 'true'
|
||||||
: value,
|
: value,
|
||||||
})
|
})
|
||||||
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
if (action === 'status') {
|
if (action === 'status') {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else if (action === 'suspicious') {
|
} else if (action === 'suspicious') {
|
||||||
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
|
const span = btn.querySelector('span')
|
||||||
|
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
|
||||||
btn.classList.toggle('bg-yellow-500/20')
|
btn.classList.toggle('bg-yellow-500/20')
|
||||||
btn.classList.toggle('text-yellow-400')
|
btn.classList.toggle('text-yellow-400')
|
||||||
btn.classList.toggle('border-yellow-500/30')
|
btn.classList.toggle('border-yellow-500/30')
|
||||||
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
btn.classList.toggle('bg-night-700')
|
btn.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
} else if (action === 'requires-admin-review') {
|
} else if (action === 'requires-admin-review') {
|
||||||
btn.textContent = value === 'true' ? 'No Review' : 'Review'
|
const span = btn.querySelector('span')
|
||||||
|
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
|
||||||
btn.classList.toggle('bg-purple-500/20')
|
btn.classList.toggle('bg-purple-500/20')
|
||||||
btn.classList.toggle('text-purple-400')
|
btn.classList.toggle('text-purple-400')
|
||||||
btn.classList.toggle('border-purple-500/30')
|
btn.classList.toggle('border-purple-500/30')
|
||||||
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
// Refresh to show updated order ID status
|
// Refresh to show updated order ID status
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} else if (action === 'kyc-requested') {
|
} else if (action === 'kyc-requested') {
|
||||||
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
const span = btn.querySelector('span')
|
||||||
|
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
|
||||||
btn.classList.toggle('bg-red-500/20')
|
btn.classList.toggle('bg-red-500/20')
|
||||||
btn.classList.toggle('text-red-400')
|
btn.classList.toggle('text-red-400')
|
||||||
btn.classList.toggle('border-red-500/30')
|
btn.classList.toggle('border-red-500/30')
|
||||||
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
btn.classList.toggle('bg-night-700')
|
btn.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
} else if (action === 'funds-blocked') {
|
} else if (action === 'funds-blocked') {
|
||||||
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
const span = btn.querySelector('span')
|
||||||
|
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
|
||||||
btn.classList.toggle('bg-red-500/20')
|
btn.classList.toggle('bg-red-500/20')
|
||||||
btn.classList.toggle('text-red-400')
|
btn.classList.toggle('text-red-400')
|
||||||
btn.classList.toggle('border-red-500/30')
|
btn.classList.toggle('border-red-500/30')
|
||||||
btn.classList.toggle('border')
|
btn.classList.toggle('border')
|
||||||
btn.classList.toggle('bg-night-700')
|
btn.classList.toggle('bg-night-700')
|
||||||
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
|
} else if (action === 'toggle-rating-active') {
|
||||||
|
const span = btn.querySelector('span')
|
||||||
|
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
|
||||||
|
btn.classList.toggle('bg-blue-500/20')
|
||||||
|
btn.classList.toggle('text-blue-400')
|
||||||
|
btn.classList.toggle('border-blue-500/30')
|
||||||
|
btn.classList.toggle('border')
|
||||||
|
btn.classList.toggle('bg-night-700')
|
||||||
|
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Error moderating comment:', error)
|
console.error('Error moderating comment:', error)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions } from 'astro:actions'
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import { COMMENT_ORDER_ID_MAX_LENGTH } from '../actions/comment'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeLoginUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl } from '../lib/redirectUrls'
|
||||||
|
|
||||||
@@ -20,6 +22,8 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
|||||||
serviceId: number
|
serviceId: number
|
||||||
parentId?: number
|
parentId?: number
|
||||||
commentId?: number
|
commentId?: number
|
||||||
|
strictCommentingEnabled?: boolean
|
||||||
|
commentSectionMessage?: string | null
|
||||||
activeRatingComment?: Prisma.CommentGetPayload<{
|
activeRatingComment?: Prisma.CommentGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
id: true
|
id: true
|
||||||
@@ -28,7 +32,16 @@ type Props = Omit<HTMLAttributes<'form'>, 'action' | 'enctype' | 'method'> & {
|
|||||||
}> | null
|
}> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serviceId, parentId, commentId, activeRatingComment, class: className, ...htmlProps } = Astro.props
|
const {
|
||||||
|
serviceId,
|
||||||
|
parentId,
|
||||||
|
commentId,
|
||||||
|
activeRatingComment,
|
||||||
|
strictCommentingEnabled,
|
||||||
|
commentSectionMessage,
|
||||||
|
class: className,
|
||||||
|
...htmlProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
|
const MIN_COMMENT_LENGTH = parentId ? 10 : 30
|
||||||
|
|
||||||
@@ -88,69 +101,83 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!parentId ? (
|
{!parentId ? (
|
||||||
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
<>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="[&:has(input[name='rating'][value='']:checked)_[data-show-if-rating]]:hidden">
|
||||||
<InputRating name="rating" label="Rating" />
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<InputRating name="rating" label="Rating" />
|
||||||
|
|
||||||
<InputWrapper label="I experienced..." name="tags">
|
<InputWrapper label="I experienced..." name="tags">
|
||||||
<label class="flex cursor-pointer items-center gap-2">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||||
<Icon name="ri:user-forbid-fill" class="size-3" />
|
<Icon name="ri:user-forbid-fill" class="size-3" />
|
||||||
KYC Issue
|
KYC Issue
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-center gap-2">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
<input type="checkbox" name="issueFundsBlocked" class="text-orange-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-orange-400">
|
<span class="flex items-center gap-1 text-xs text-orange-400">
|
||||||
<Icon name="ri:wallet-3-fill" class="size-3" />
|
<Icon name="ri:wallet-3-fill" class="size-3" />
|
||||||
Funds Blocked
|
Funds Blocked
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
|
|
||||||
<InputText
|
<InputText
|
||||||
label="Order ID"
|
label="Order ID"
|
||||||
name="orderId"
|
name="orderId"
|
||||||
inputProps={{
|
inputProps={{
|
||||||
maxlength: 100,
|
maxlength: COMMENT_ORDER_ID_MAX_LENGTH,
|
||||||
placeholder: 'Order ID / URL / Proof',
|
placeholder: 'Order ID / URL / Proof',
|
||||||
class: 'bg-night-800',
|
class: 'bg-night-800',
|
||||||
}}
|
required: strictCommentingEnabled,
|
||||||
descriptionLabel="Only visible to admins, to verify your comment"
|
}}
|
||||||
class="grow"
|
descriptionLabel="Only visible to admins, to verify your comment"
|
||||||
/>
|
class="grow"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-start justify-end gap-2">
|
<div class="mt-4 flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||||
{!!activeRatingComment?.rating && (
|
{!!activeRatingComment?.rating && (
|
||||||
<div
|
<div
|
||||||
class="rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
class="mt-1 rounded-sm bg-yellow-500/10 px-2 py-1.5 text-xs text-yellow-400"
|
||||||
data-show-if-rating
|
data-show-if-rating
|
||||||
>
|
|
||||||
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
|
||||||
<a
|
|
||||||
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
|
||||||
class="inline-flex items-center gap-1 underline"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
Your previous rating
|
<Icon name="ri:information-line" class="mr-1 inline size-3.5" />
|
||||||
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
<a
|
||||||
</a>
|
href={`${Astro.url.origin}${Astro.url.pathname}#comment-${activeRatingComment.id}`}
|
||||||
of
|
class="inline-flex items-center gap-1 underline"
|
||||||
{[
|
target="_blank"
|
||||||
activeRatingComment.rating.toLocaleString(),
|
rel="noopener noreferrer"
|
||||||
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
>
|
||||||
]}
|
Your previous rating
|
||||||
won't count for the total.
|
<Icon name="ri:external-link-line" class="inline size-2.5 align-[-0.1em]" />
|
||||||
</div>
|
</a>
|
||||||
)}
|
of
|
||||||
|
{[
|
||||||
|
activeRatingComment.rating.toLocaleString(),
|
||||||
|
<Icon name="ri:star-fill" class="inline size-3 align-[-0.1em]" />,
|
||||||
|
]}
|
||||||
|
won't count for the total.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
<div class="flex flex-wrap items-start justify-end gap-x-4 gap-y-2">
|
||||||
|
{!!commentSectionMessage && (
|
||||||
|
<div class="flex items-start gap-1 pt-1.5">
|
||||||
|
<Icon name="ri:information-line" class="mt-1.25 inline size-3.5" />
|
||||||
|
<div class="prose prose-invert prose-sm text-day-200 max-w-none grow">
|
||||||
|
<Markdown content={commentSectionMessage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" label="Send" icon="ri:send-plane-2-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div class="flex items-center justify-end gap-2">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
<Button type="submit" label="Reply" icon="ri:reply-line" />
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Props = {
|
|||||||
name: true
|
name: true
|
||||||
description: true
|
description: true
|
||||||
createdAt: true
|
createdAt: true
|
||||||
|
strictCommentingEnabled: true
|
||||||
|
commentSectionMessage: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -71,12 +73,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
|||||||
'Failed to fetch comments',
|
'Failed to fetch comments',
|
||||||
async () =>
|
async () =>
|
||||||
await prisma.comment.findMany(
|
await prisma.comment.findMany(
|
||||||
makeCommentsNestedQuery({
|
await makeCommentsNestedQuery({
|
||||||
depth: MAX_COMMENT_DEPTH,
|
depth: MAX_COMMENT_DEPTH,
|
||||||
user,
|
user,
|
||||||
showPending: params.showPending,
|
showPending: params.showPending,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
|
highlightedCommentId: params.comment,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
@@ -172,7 +175,13 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
|||||||
comment: comments.map(makeReplySchema),
|
comment: comments.map(makeReplySchema),
|
||||||
} as WithContext<DiscussionForumPosting>}
|
} as WithContext<DiscussionForumPosting>}
|
||||||
/>
|
/>
|
||||||
<CommentReply serviceId={service.id} activeRatingComment={activeRatingComment} class="xs:mb-4 mb-2" />
|
<CommentReply
|
||||||
|
serviceId={service.id}
|
||||||
|
activeRatingComment={activeRatingComment}
|
||||||
|
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||||
|
commentSectionMessage={service.commentSectionMessage}
|
||||||
|
class="xs:mb-4 mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-2">
|
<div class="mb-6 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -257,6 +266,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
|||||||
showPending={params.showPending}
|
showPending={params.showPending}
|
||||||
serviceSlug={service.slug}
|
serviceSlug={service.slug}
|
||||||
itemReviewedId={itemReviewedId}
|
itemReviewedId={itemReviewedId}
|
||||||
|
strictCommentingEnabled={service.strictCommentingEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
51
web/src/components/DevToolsMessageScript.astro
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||||
|
|
||||||
|
const logoStyle = `
|
||||||
|
padding: 0 119.5px;
|
||||||
|
display: block;
|
||||||
|
line-height: 64px;
|
||||||
|
background-size: auto 64px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: 50% 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 240 64'%3E%3Crect width='239' height='63' x='.5' y='.5' fill='%23101413' stroke='%23292B2A' rx='7.5'/%3E%3Cpath fill='%233BDB78' d='M19 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1H19Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.9 10.8a1 1 0 0 0-.1.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6L47 23.5a1 1 0 0 1 .8-1.5ZM75 22h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H74v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H75a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6L186 37.2l-6.7-14.6a1 1 0 0 0-1-.6H175Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z'/%3E%3C/svg%3E");
|
||||||
|
`
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
console.log.bind(
|
||||||
|
console,
|
||||||
|
`\n%c \n%c\n 👋%c Hi there! %c\n\n‣ We included source maps, so you can easily inspect the code. 🕵🏻♂️\n‣ Everything works with JavaScript disabled.\n‣ Source code: ${SOURCE_CODE_URL}`,
|
||||||
|
logoStyle,
|
||||||
|
`
|
||||||
|
font-family: cursive;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
font-family: cursive;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#d97706 0%,
|
||||||
|
#f59e0b 20%,
|
||||||
|
#f97316 40%,
|
||||||
|
#ea580c 60%,
|
||||||
|
#f97316 80%,
|
||||||
|
#f59e0b 100%
|
||||||
|
) -100%/ 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
79
web/src/components/DynamicFavicon.astro
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
|
import { prisma } from '../lib/prisma'
|
||||||
|
|
||||||
|
const user = Astro.locals.user
|
||||||
|
|
||||||
|
const hasUnreadNotifications = await Astro.locals.banners.try(
|
||||||
|
'Error getting unread notification count',
|
||||||
|
async () =>
|
||||||
|
user
|
||||||
|
? !!(await prisma.notification.findFirst({
|
||||||
|
where: { userId: user.id, read: false },
|
||||||
|
select: { id: true },
|
||||||
|
}))
|
||||||
|
: false,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
function addBadgeIfUnread(href: string) {
|
||||||
|
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'production' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'development' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DEPLOYMENT_MODE === 'staging' && (
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
sizes="any"
|
||||||
|
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('sse:new-notification', () => {
|
||||||
|
const links = document.querySelectorAll('link[rel="icon"]')
|
||||||
|
links.forEach((link) => {
|
||||||
|
const href = link.getAttribute('href')
|
||||||
|
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
|
||||||
|
const newHref = href.replace('.svg', '-badge.svg')
|
||||||
|
link.setAttribute('href', newHref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { SOURCE_CODE_URL, I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
import { SOURCE_CODE_URL } from 'astro:env/client'
|
||||||
|
import { I2P_ADDRESS, ONION_ADDRESS } from 'astro:env/server'
|
||||||
|
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ const links = [
|
|||||||
icon: 'ri:plug-line',
|
icon: 'ri:plug-line',
|
||||||
external: false,
|
external: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/feeds',
|
||||||
|
label: 'RSS',
|
||||||
|
icon: 'ri:rss-line',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/about',
|
href: '/about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
@@ -49,7 +56,10 @@ const links = [
|
|||||||
const { class: className, ...htmlProps } = Astro.props
|
const { class: className, ...htmlProps } = Astro.props
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class={cn('flex items-center justify-center gap-6 p-4', className)} {...htmlProps}>
|
<footer
|
||||||
|
class={cn('xs:gap-x-6 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 p-4', className)}
|
||||||
|
{...htmlProps}
|
||||||
|
>
|
||||||
{
|
{
|
||||||
links.map(
|
links.map(
|
||||||
({ href, label, icon, external }) =>
|
({ href, label, icon, external }) =>
|
||||||
@@ -58,9 +68,9 @@ const { class: className, ...htmlProps } = Astro.props
|
|||||||
href={href}
|
href={href}
|
||||||
target={external ? '_blank' : undefined}
|
target={external ? '_blank' : undefined}
|
||||||
rel={external ? 'noopener noreferrer' : undefined}
|
rel={external ? 'noopener noreferrer' : undefined}
|
||||||
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
|
class="text-day-500 xs:gap-1 flex items-center gap-0.5 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||||
>
|
>
|
||||||
<Icon name={icon} class="h-4 w-4" />
|
<Icon name={icon} class="xs:opacity-100 h-4 w-4 opacity-40" />
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
|||||||
import { sample } from 'lodash-es'
|
import { sample } from 'lodash-es'
|
||||||
|
|
||||||
import { splashTexts } from '../constants/splashTexts'
|
import { splashTexts } from '../constants/splashTexts'
|
||||||
|
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
|
||||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||||
|
|
||||||
import AdminOnly from './AdminOnly.astro'
|
import AdminOnly from './AdminOnly.astro'
|
||||||
@@ -37,6 +37,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
transition:name="header-container"
|
transition:name="header-container"
|
||||||
|
aria-label="Header"
|
||||||
>
|
>
|
||||||
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
|
<nav class={cn('container mx-auto flex h-full w-full items-stretch justify-between px-4', classNames?.nav)}>
|
||||||
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
<div class="@container -ml-4 flex max-w-[192px] grow-99999 items-center">
|
||||||
@@ -123,6 +124,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
|||||||
transition:name="header-admin-link"
|
transition:name="header-admin-link"
|
||||||
text="Admin Dashboard"
|
text="Admin Dashboard"
|
||||||
position="left"
|
position="left"
|
||||||
|
aria-label="Admin Dashboard"
|
||||||
>
|
>
|
||||||
<Icon name="ri:home-gear-line" class="size-10" />
|
<Icon name="ri:home-gear-line" class="size-10" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const count =
|
|||||||
user && (
|
user && (
|
||||||
<a
|
<a
|
||||||
href="/notifications"
|
href="/notifications"
|
||||||
|
data-notification-count-link
|
||||||
|
data-current-count={count}
|
||||||
class={cn(
|
class={cn(
|
||||||
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
|
||||||
className
|
className
|
||||||
@@ -35,11 +37,32 @@ const count =
|
|||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
>
|
>
|
||||||
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
<Icon name="material-symbols:notifications-outline" class="size-5" />
|
||||||
{count > 0 && (
|
<span
|
||||||
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500">
|
data-notification-count-badge
|
||||||
{count > 99 ? '★' : count.toLocaleString()}
|
class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
|
||||||
</span>
|
>
|
||||||
)}
|
{count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('sse:new-notification', () => {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
|
||||||
|
const currentCount = Number(link.getAttribute('data-current-count') || 0)
|
||||||
|
const newCount = currentCount + 1
|
||||||
|
|
||||||
|
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
|
||||||
|
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
link.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
|
||||||
|
)
|
||||||
|
|
||||||
|
link.setAttribute('data-current-count', String(newCount))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as htmx from 'htmx.org'
|
import htmx from 'htmx.org'
|
||||||
|
|
||||||
htmx.config.globalViewTransitions = false
|
htmx.config.globalViewTransitions = false
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
|||||||
iconClass?: string
|
iconClass?: string
|
||||||
description?: MarkdownString
|
description?: MarkdownString
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
noTransitionPersist?: boolean
|
|
||||||
}[]
|
}[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
selectedValue?: Multiple extends true ? string[] : string
|
selectedValue?: Multiple extends true ? string[] : string
|
||||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
transition:persist
|
||||||
type={multiple ? 'checkbox' : 'radio'}
|
type={multiple ? 'checkbox' : 'radio'}
|
||||||
name={wrapperProps.name}
|
name={wrapperProps.name}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|||||||
63
web/src/components/InputCheckbox.astro
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
|
import type InputWrapper from './InputWrapper.astro'
|
||||||
|
import type { AstroChildren } from '../lib/astro'
|
||||||
|
import type { ComponentProps } from 'astro/types'
|
||||||
|
|
||||||
|
type Props = Pick<ComponentProps<typeof InputWrapper>, 'error' | 'name' | 'required'> & {
|
||||||
|
disabled?: boolean
|
||||||
|
checked?: boolean
|
||||||
|
descriptionInline?: string
|
||||||
|
id?: string
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
label: string
|
||||||
|
children?: undefined
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
label?: undefined
|
||||||
|
children: AstroChildren
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { disabled, name, required, error, id, label, checked, descriptionInline } = Astro.props
|
||||||
|
|
||||||
|
const hasError = !!error && error.length > 0
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
'inline-flex cursor-pointer items-center gap-2',
|
||||||
|
hasError && 'text-red-300',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
transition:persist
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
checked={checked}
|
||||||
|
class={cn(disabled && 'opacity-50')}
|
||||||
|
/>
|
||||||
|
<span class="text-sm leading-none text-pretty">{label ?? <slot />}</span>
|
||||||
|
{descriptionInline && <p class="text-day-400 text-xs">{descriptionInline}</p>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{
|
||||||
|
hasError &&
|
||||||
|
(typeof error === 'string' ? (
|
||||||
|
<p class="text-sm text-red-500">{error}</p>
|
||||||
|
) : (
|
||||||
|
<ul class="text-sm text-red-500">
|
||||||
|
{error.map((e) => (
|
||||||
|
<li>{e}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -14,10 +14,11 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' |
|
|||||||
value: string
|
value: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}[]
|
}[]
|
||||||
selectProps?: Omit<HTMLAttributes<'select'>, 'name'>
|
selectProps?: Omit<HTMLAttributes<'select'>, 'name' | 'value'>
|
||||||
|
selectedValue?: string[] | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { options, selectProps, ...wrapperProps } = Astro.props
|
const { options, selectProps, selectedValue, ...wrapperProps } = Astro.props
|
||||||
|
|
||||||
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
const inputId = selectProps?.id ?? Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||||
@@ -39,7 +40,15 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
>
|
>
|
||||||
{
|
{
|
||||||
options.map((option) => (
|
options.map((option) => (
|
||||||
<option value={option.value} disabled={option.disabled}>
|
<option
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
selected={
|
||||||
|
Array.isArray(selectedValue)
|
||||||
|
? selectedValue.includes(option.value)
|
||||||
|
: selectedValue === option.value
|
||||||
|
}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
|||||||
class={cn(
|
class={cn(
|
||||||
baseInputClassNames.input,
|
baseInputClassNames.input,
|
||||||
baseInputClassNames.textarea,
|
baseInputClassNames.textarea,
|
||||||
|
!!inputProps?.rows && 'h-auto',
|
||||||
inputProps?.class,
|
inputProps?.class,
|
||||||
hasError && baseInputClassNames.error,
|
hasError && baseInputClassNames.error,
|
||||||
!!inputProps?.disabled && baseInputClassNames.disabled
|
!!inputProps?.disabled && baseInputClassNames.disabled
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
|
|||||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||||
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
|
||||||
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
|
||||||
<label for={inputId}>{label}</label>
|
<label for={inputId} transition:persist>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
{required && '*'}
|
{required && '*'}
|
||||||
</legend>
|
</legend>
|
||||||
{!!descriptionLabel && (
|
{!!descriptionLabel && (
|
||||||
|
|||||||
32
web/src/components/NotificationEventsScript.astro
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
|
||||||
|
import {
|
||||||
|
makeBrowserNotificationOptions,
|
||||||
|
makeBrowserNotificationTitle,
|
||||||
|
} from '../lib/client/notificationOptions'
|
||||||
|
|
||||||
|
document.addEventListener('sse:new-notification', (event) => {
|
||||||
|
if (isBrowserNotificationsEnabled()) {
|
||||||
|
const payload = event.detail
|
||||||
|
const notification = showBrowserNotification(
|
||||||
|
makeBrowserNotificationTitle(payload.title),
|
||||||
|
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle notification click
|
||||||
|
if (notification) {
|
||||||
|
notification.onclick = () => {
|
||||||
|
const defaultAction = payload.actions.find((a) => a.url) ?? payload.actions[0]
|
||||||
|
if (defaultAction?.url) {
|
||||||
|
window.open(defaultAction.url, '_blank')
|
||||||
|
}
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -9,6 +9,7 @@ import defaultOGImage from '../assets/ogimage.png'
|
|||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
import { urlWithParams } from '../lib/urls'
|
import { urlWithParams } from '../lib/urls'
|
||||||
|
|
||||||
|
import type { VerificationStatus } from '@prisma/client'
|
||||||
import type { APIContext } from 'astro'
|
import type { APIContext } from 'astro'
|
||||||
import type { Prettify } from 'ts-essentials'
|
import type { Prettify } from 'ts-essentials'
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export const ogImageTemplates = {
|
|||||||
categories,
|
categories,
|
||||||
score,
|
score,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
verificationStatus,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -116,6 +118,7 @@ export const ogImageTemplates = {
|
|||||||
}[]
|
}[]
|
||||||
score: number
|
score: number
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
verificationStatus: VerificationStatus | null
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
@@ -272,6 +275,37 @@ export const ogImageTemplates = {
|
|||||||
>
|
>
|
||||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{verificationStatus === 'VERIFICATION_FAILED' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform: 'rotate(-20deg)',
|
||||||
|
fontSize: 200,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'red',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
boxShadow: '0 0 15px 30px rgba(0, 0, 0, 0.5)',
|
||||||
|
border: '15px solid red',
|
||||||
|
borderRadius: 15,
|
||||||
|
padding: '10px 50px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SCAM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
defaultOptions
|
defaultOptions
|
||||||
|
|||||||
151
web/src/components/PressAssets.astro
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
import favicon from '../../public/favicon.svg'
|
||||||
|
import logoMiniFull from '../assets/logo/logo-mini-full.svg'
|
||||||
|
import logoNormal from '../assets/logo/logo-normal.svg'
|
||||||
|
import logoSmall from '../assets/logo/logo-small.svg'
|
||||||
|
import reviewBadgeLongBlack from '../assets/review-badge/long-black.svg'
|
||||||
|
import reviewBadgeLongWhite from '../assets/review-badge/long-white.svg'
|
||||||
|
import reviewBadgeShortBlack from '../assets/review-badge/short-black.svg'
|
||||||
|
import reviewBadgeShortWhite from '../assets/review-badge/short-white.svg'
|
||||||
|
|
||||||
|
import Button from './Button.astro'
|
||||||
|
import MyPicture from './MyPicture.astro'
|
||||||
|
|
||||||
|
const categories: {
|
||||||
|
title: string
|
||||||
|
assets: {
|
||||||
|
name: string
|
||||||
|
path: typeof logoNormal
|
||||||
|
alt: string
|
||||||
|
}[]
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
title: 'Logos',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'Logo',
|
||||||
|
path: logoNormal,
|
||||||
|
alt: 'KYCnot.me logo normal version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo small',
|
||||||
|
path: logoSmall,
|
||||||
|
alt: 'KYCnot.me logo small version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo mini',
|
||||||
|
path: logoMiniFull,
|
||||||
|
alt: 'KYCnot.me logo mini version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Logo icon',
|
||||||
|
path: favicon,
|
||||||
|
alt: 'KYCnot.me logo icon version',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Review badges',
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
name: 'Review badge (long black)',
|
||||||
|
path: reviewBadgeLongBlack,
|
||||||
|
alt: 'KYCnot.me review badge long black version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (long white)',
|
||||||
|
path: reviewBadgeLongWhite,
|
||||||
|
alt: 'KYCnot.me review badge long white version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (short black)',
|
||||||
|
path: reviewBadgeShortBlack,
|
||||||
|
alt: 'KYCnot.me review badge short black version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Review badge (short white)',
|
||||||
|
path: reviewBadgeShortWhite,
|
||||||
|
alt: 'KYCnot.me review badge short white version',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="not-prose mb-16 space-y-8">
|
||||||
|
{
|
||||||
|
categories.map((category) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h3 class="font-title mb-2 text-center text-xl font-semibold text-white">{category.title}</h3>
|
||||||
|
<ul class="xs:grid-cols-2 grid grid-cols-1 gap-6">
|
||||||
|
{category.assets.map((asset) => (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
class="bg-transparency-grid mx-auto flex aspect-[3/1] max-w-sm items-center justify-center rounded-lg p-4"
|
||||||
|
style={{
|
||||||
|
'--transparency-grid-color-1': 'var(--color-night-600)',
|
||||||
|
'--transparency-grid-color-2': 'var(--color-night-500)',
|
||||||
|
'--transparency-grid-size': 'calc(var(--spacing) * 4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyPicture
|
||||||
|
src={asset.path}
|
||||||
|
alt={asset.alt}
|
||||||
|
pictureAttributes={{
|
||||||
|
class: 'contents',
|
||||||
|
}}
|
||||||
|
class="max-h-full min-h-8 max-w-full min-w-8 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={asset.path.src}
|
||||||
|
download={asset.name}
|
||||||
|
label={asset.name}
|
||||||
|
size="sm"
|
||||||
|
color="white"
|
||||||
|
variant="faded"
|
||||||
|
icon="ri:download-line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg-transparency-grid {
|
||||||
|
--transparency-grid-color-1: #fff;
|
||||||
|
--transparency-grid-color-2: #ccc;
|
||||||
|
--transparency-grid-size: calc(var(--spacing) * 8);
|
||||||
|
|
||||||
|
background-color: var(--transparency-grid-color-1);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--transparency-grid-color-2) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--transparency-grid-color-2) 75%,
|
||||||
|
var(--transparency-grid-color-2)
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--transparency-grid-color-2) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--transparency-grid-color-2) 75%,
|
||||||
|
var(--transparency-grid-color-2)
|
||||||
|
);
|
||||||
|
background-size: var(--transparency-grid-size) var(--transparency-grid-size);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
calc(var(--transparency-grid-size) / 2) calc(var(--transparency-grid-size) / 2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
||||||
|
|
||||||
|
import { SUPPORT_EMAIL } from '../constants/project'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
|
|
||||||
import Button from './Button.astro'
|
import Button from './Button.astro'
|
||||||
@@ -15,30 +16,29 @@ type Props = HTMLAttributes<'div'> & {
|
|||||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||||
select: {
|
select: {
|
||||||
endpoint: true
|
endpoint: true
|
||||||
userAgent: true
|
|
||||||
}
|
}
|
||||||
}>[]
|
}>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
||||||
|
|
||||||
// TODO: Feature flag, enabled only for admins
|
|
||||||
if (!Astro.locals.user?.admin) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-push-notification-banner
|
data-push-notification-banner
|
||||||
data-dismissed={undefined /* Updated by client script */}
|
data-dismissed={undefined as true | undefined /* Updated by client script */}
|
||||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
data-notification-supported={undefined as true | undefined /* Updated by client script */}
|
||||||
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||||
data-is-enabled={undefined /* Updated by client script */}
|
data-is-enabled={undefined as true | undefined /* Updated by client script */}
|
||||||
|
data-loaded={undefined as true | undefined /* Updated by client script */}
|
||||||
|
data-notification-permissions={undefined as
|
||||||
|
| NotificationPermission
|
||||||
|
| undefined /* Updated by client script */}
|
||||||
|
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||||
class={cn(
|
class={cn(
|
||||||
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
'no-js:hidden xs:grid-cols-[auto_auto] xs:justify-between relative isolate grid items-center justify-stretch gap-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4 not-data-loaded:hidden',
|
||||||
'data-dismissed:hidden',
|
'data-dismissed:hidden',
|
||||||
hideIfEnabled && 'data-is-enabled:hidden',
|
hideIfEnabled && 'data-is-enabled:hidden',
|
||||||
'not-data-supports-push-notifications:hidden',
|
'not-data-notification-supported:hidden',
|
||||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -60,25 +60,18 @@ if (!Astro.locals.user?.admin) {
|
|||||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium text-blue-100">
|
<h3 class="font-medium text-blue-100" data-banner-title>Turn on push notifications?</h3>
|
||||||
<span data-show-if-enabled>Push notifications enabled</span>
|
<p class="text-sm text-blue-200/80" data-banner-description>Get notifications on this device.</p>
|
||||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-blue-200/80">
|
|
||||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
|
||||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="xs:justify-end flex shrink flex-wrap items-center justify-center gap-2">
|
||||||
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||||
<Button
|
<Button
|
||||||
as="span"
|
as="span"
|
||||||
label="Yes, notify me"
|
label="Yes, notify me"
|
||||||
color="white"
|
color="white"
|
||||||
data-push-action="subscribe"
|
data-push-action="subscribe"
|
||||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
|
||||||
data-show-if-disabled
|
data-show-if-disabled
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -87,10 +80,21 @@ if (!Astro.locals.user?.admin) {
|
|||||||
color="white"
|
color="white"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
data-push-action="unsubscribe"
|
data-push-action="unsubscribe"
|
||||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
|
||||||
data-show-if-enabled
|
data-show-if-enabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="col-span-full flex items-center justify-center gap-2 leading-tight text-red-500 has-[[data-error-message]:empty]:hidden"
|
||||||
|
>
|
||||||
|
<Icon name="ri:error-warning-line" class="size-5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
<span data-error-message></span>
|
||||||
|
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-red-300 underline hover:text-red-200">
|
||||||
|
Contact support
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -134,240 +138,128 @@ if (!Astro.locals.user?.admin) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/////////////////////////////////////////////////////////
|
///////////////////////////////////////////
|
||||||
// Script to style when notifications enabled. //
|
// Script to handle push notifications. //
|
||||||
////////////////////////////////////////////////////////
|
///////////////////////////////////////////
|
||||||
|
|
||||||
type ServerSubscription = {
|
import {
|
||||||
endpoint: string
|
subscribeToPushNotifications,
|
||||||
userAgent: string | null
|
unsubscribeFromPushNotifications,
|
||||||
}
|
parsePushSubscriptions,
|
||||||
|
isCurrentDeviceSubscribed,
|
||||||
|
supportsPushNotifications,
|
||||||
|
type SafeResult,
|
||||||
|
} from '../lib/client/clientPushNotifications'
|
||||||
|
|
||||||
/** Parse push subscriptions from string */
|
import {
|
||||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
enableBrowserNotifications,
|
||||||
try {
|
disableBrowserNotifications,
|
||||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
isBrowserNotificationsEnabled,
|
||||||
|
supportsBrowserNotifications,
|
||||||
|
} from '../lib/client/browserNotifications'
|
||||||
|
|
||||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
async function setDataAttributes(banner: HTMLElement) {
|
||||||
|
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
|
||||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
return
|
||||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects')
|
|
||||||
}
|
|
||||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
|
||||||
}
|
|
||||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
|
||||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscriptions as ServerSubscription[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse push subscriptions:', error)
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if current device has an active push subscription */
|
banner.dataset.notificationSupported = ''
|
||||||
async function getCurrentPushSubscription() {
|
banner.dataset.notificationPermissions = Notification.permission
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) return null
|
|
||||||
|
|
||||||
return await registration.pushManager.getSubscription()
|
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||||
} catch (error) {
|
const titleElement = banner.querySelector<HTMLElement>('[data-banner-title]')
|
||||||
console.error('Error getting current push subscription:', error)
|
const descriptionElement = banner.querySelector<HTMLElement>('[data-banner-description]')
|
||||||
return null
|
|
||||||
|
if (await isCurrentDeviceSubscribed(serverSubscriptions)) {
|
||||||
|
if (titleElement) titleElement.textContent = 'Push notifications enabled'
|
||||||
|
if (descriptionElement) descriptionElement.textContent = 'Turn push notifications off for this device?'
|
||||||
|
banner.dataset.isEnabled = ''
|
||||||
|
banner.dataset.loaded = ''
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if current subscription matches any server subscription */
|
if (isBrowserNotificationsEnabled()) {
|
||||||
function isCurrentDeviceSubscribed(
|
if (titleElement) titleElement.textContent = 'Browser notifications enabled'
|
||||||
currentSubscription: PushSubscription | null,
|
if (descriptionElement)
|
||||||
serverSubscriptions: ServerSubscription[]
|
descriptionElement.textContent = 'Turn off notifications? (They only work while the tab is open.)'
|
||||||
) {
|
banner.dataset.isEnabled = ''
|
||||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
banner.dataset.loaded = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentEndpoint = currentSubscription.endpoint
|
// Default state (disabled)
|
||||||
const currentUserAgent = navigator.userAgent
|
if (titleElement) titleElement.textContent = 'Turn on push notifications?'
|
||||||
|
if (descriptionElement) descriptionElement.textContent = 'Get notifications on this device.'
|
||||||
return serverSubscriptions.some(
|
banner.dataset.loaded = ''
|
||||||
(sub) =>
|
|
||||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('astro:page-load', async () => {
|
document.addEventListener('astro:page-load', async () => {
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
await setDataAttributes(banner)
|
||||||
const currentSubscription = await getCurrentPushSubscription()
|
|
||||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
|
||||||
|
|
||||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
const vapidPublicKey = banner.dataset.vapidPublicKey
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||||
/////////////////////////////////////////////////////////////
|
button.addEventListener('click', async (event) => {
|
||||||
// Script to handle push notification subscription. //
|
event.preventDefault()
|
||||||
/////////////////////////////////////////////////////////////
|
event.stopPropagation()
|
||||||
|
|
||||||
import type { actions } from 'astro:actions'
|
const action = button.dataset.pushAction
|
||||||
import type { ActionInput } from '../lib/astroActions'
|
if (action !== 'subscribe' && action !== 'unsubscribe') {
|
||||||
|
console.error('Invalid push action:', action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
/** Utility function to convert VAPID key */
|
let result: SafeResult
|
||||||
function urlB64ToUint8Array(base64String: string) {
|
|
||||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
|
||||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
|
||||||
const base64 = cleaned + padding
|
|
||||||
|
|
||||||
const rawData = window.atob(base64)
|
if (action === 'subscribe') {
|
||||||
const outputArray = new Uint8Array(rawData.length)
|
const pushResult = vapidPublicKey
|
||||||
|
? await subscribeToPushNotifications(vapidPublicKey)
|
||||||
|
: { success: false as const, error: 'VAPID public key not found' }
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
if (pushResult.success) {
|
||||||
outputArray[i] = rawData.charCodeAt(i)
|
result = pushResult
|
||||||
}
|
} else {
|
||||||
return outputArray
|
console.error(
|
||||||
}
|
"Can't enable push notifications, trying browser notifications.",
|
||||||
|
pushResult.error
|
||||||
|
)
|
||||||
|
const browserResult = await enableBrowserNotifications()
|
||||||
|
|
||||||
/** Check for browser support */
|
if (browserResult.success) {
|
||||||
function checkSupport() {
|
result = browserResult
|
||||||
const isSecure =
|
} else {
|
||||||
window.isSecureContext ||
|
console.error("Can't enable browser notifications:", browserResult.error)
|
||||||
window.location.hostname === 'localhost' ||
|
result = {
|
||||||
window.location.hostname === '127.0.0.1'
|
success: false,
|
||||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
error: `Can't enable either push or browser notifications. Push: ${pushResult.error}. Browser: ${browserResult.error}`,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pushResult = await unsubscribeFromPushNotifications()
|
||||||
|
const browserResult = disableBrowserNotifications()
|
||||||
|
|
||||||
async function registerServiceWorker() {
|
const success = pushResult.success || browserResult.success
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
|
||||||
console.log('Service Worker registered:', registration)
|
|
||||||
|
|
||||||
const readyRegistration = await navigator.serviceWorker.ready
|
result = success
|
||||||
console.log('Service Worker is active and ready:', readyRegistration)
|
? { success: true }
|
||||||
|
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
|
||||||
|
}
|
||||||
|
|
||||||
return readyRegistration
|
if (!result.success) {
|
||||||
} catch (error) {
|
console.error(result.error)
|
||||||
console.error('Service Worker registration failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscribeToPush(vapidPublicKey: string) {
|
const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
|
||||||
try {
|
if (errorMessageElement) {
|
||||||
if (!checkSupport()) return
|
errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
|
||||||
|
}
|
||||||
|
|
||||||
// Request notification permission
|
return
|
||||||
const permission = await Notification.requestPermission()
|
}
|
||||||
if (permission !== 'granted') {
|
|
||||||
alert('Push notifications permission denied')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const registration = await registerServiceWorker()
|
window.location.reload()
|
||||||
|
})
|
||||||
// Subscribe to push manager
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
|
||||||
})
|
|
||||||
|
|
||||||
const p256dh = subscription.getKey('p256dh')
|
|
||||||
const auth = subscription.getKey('auth')
|
|
||||||
|
|
||||||
// Send subscription to server
|
|
||||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
|
||||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
|
||||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Push subscription successful')
|
|
||||||
|
|
||||||
// Reload page to update UI
|
|
||||||
window.location.reload()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push subscription failed:', error)
|
|
||||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unsubscribeFromPush() {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
|
||||||
if (!registration) {
|
|
||||||
console.log('No service worker registration found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscription = await registration.pushManager.getSubscription()
|
|
||||||
if (!subscription) {
|
|
||||||
console.log('No push subscription found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe from browser
|
|
||||||
await subscription.unsubscribe()
|
|
||||||
|
|
||||||
// Remove from server
|
|
||||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
endpoint: subscription.endpoint,
|
|
||||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Push unsubscription successful')
|
|
||||||
|
|
||||||
// Reload page to update UI
|
|
||||||
window.location.reload()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Push unsubscription failed:', error)
|
|
||||||
alert('Failed to unsubscribe from push notifications')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
const supportsPushNotifications = checkSupport()
|
|
||||||
if (supportsPushNotifications) {
|
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
|
||||||
element.dataset.supportsPushNotifications = ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
|
||||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
|
||||||
if (!vapidPublicKey) {
|
|
||||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
button.addEventListener('click', async (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const action = button.dataset.pushAction
|
|
||||||
if (action === 'subscribe') {
|
|
||||||
await subscribeToPush(vapidPublicKey)
|
|
||||||
} else if (action === 'unsubscribe') {
|
|
||||||
await unsubscribeFromPush()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||||
import type { Review, WithContext } from 'schema-dts'
|
import type { Review, WithContext } from 'schema-dts'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'div'> & {
|
type Props = Polymorphic<{
|
||||||
|
as: HTMLTag
|
||||||
score: number
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
itemReviewedId?: string
|
||||||
}
|
showInfo?: boolean
|
||||||
|
children?: never
|
||||||
|
}>
|
||||||
|
|
||||||
const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
const {
|
||||||
|
as: Tag = 'div',
|
||||||
|
score,
|
||||||
|
label,
|
||||||
|
total = 100,
|
||||||
|
class: className,
|
||||||
|
itemReviewedId,
|
||||||
|
showInfo = false,
|
||||||
|
...htmlProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1)
|
||||||
|
|
||||||
@@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<div
|
<Tag
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||||
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
|||||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||||
class="stroke-night-700"></path>
|
class="stroke-night-700"></path>
|
||||||
|
|
||||||
<!-- Info icon -->
|
{
|
||||||
<!-- <path
|
showInfo && (
|
||||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
<path
|
||||||
fill="white"
|
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||||
fill-opacity="0.67"></path> -->
|
class="text-current/60"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Tag>
|
||||||
|
|||||||
@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'div'> & {
|
type Props = Polymorphic<{
|
||||||
|
as: HTMLTag
|
||||||
score: number
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
itemReviewedId?: string
|
||||||
}
|
showInfo?: boolean
|
||||||
|
children?: never
|
||||||
|
}>
|
||||||
|
|
||||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
const {
|
||||||
|
as: Tag = 'div',
|
||||||
|
score,
|
||||||
|
label,
|
||||||
|
total = 10,
|
||||||
|
class: className,
|
||||||
|
itemReviewedId,
|
||||||
|
showInfo = false,
|
||||||
|
...htmlProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<Tag
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
!!itemReviewedId && (
|
!!itemReviewedId && (
|
||||||
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<!-- <svg
|
{
|
||||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
showInfo && (
|
||||||
viewBox="0 0 12 12"
|
<svg
|
||||||
fill="currentColor"
|
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||||
>
|
viewBox="0 0 12 12"
|
||||||
<path
|
fill="currentColor"
|
||||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
>
|
||||||
></path>
|
<path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
|
||||||
</svg> -->
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||||
</div>
|
</Tag>
|
||||||
|
|||||||
114
web/src/components/ServerEventsScript.astro
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
if (!Astro.locals.user) return
|
||||||
|
---
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import type { ServerEventsEvent, SSEEventMap } from '../lib/serverEventsTypes'
|
||||||
|
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5
|
||||||
|
const RECONNECT_DELAY = 2_000
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null
|
||||||
|
let reconnectTimeout: number | null = null
|
||||||
|
let reconnectAttempts = 0
|
||||||
|
|
||||||
|
startServerEventsListener()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function startServerEventsListener() {
|
||||||
|
stopServerEventsListener()
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource('/internal-api/server-events')
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
// NOTE: Disable sse: events when user is not logged in
|
||||||
|
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||||
|
stopServerEventsListener()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data as string)
|
||||||
|
|
||||||
|
if (isServerEventsEvent(data)) {
|
||||||
|
const eventType = `sse:${data.type}` as const
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.error('Invalid server events event:', data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing server events data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('Server events error:', error)
|
||||||
|
|
||||||
|
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server events listener:', error)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopServerEventsListener() {
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
console.info('Disconnected from server events listener')
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
console.error('Max reconnection attempts reached, giving up')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
|
||||||
|
reconnectAttempts++
|
||||||
|
|
||||||
|
const delayStr = String(delay)
|
||||||
|
const attemptsStr = String(reconnectAttempts)
|
||||||
|
const maxAttemptsStr = String(MAX_RECONNECT_ATTEMPTS)
|
||||||
|
console.info(`Attempting to reconnect in ${delayStr}ms (attempt ${attemptsStr}/${maxAttemptsStr})`)
|
||||||
|
|
||||||
|
reconnectTimeout = window.setTimeout(() => {
|
||||||
|
startServerEventsListener()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isServerEventsEvent(event: unknown): event is ServerEventsEvent {
|
||||||
|
if (typeof event !== 'object' || event === null) return false
|
||||||
|
const e = event as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
'type' in e &&
|
||||||
|
typeof e.type === 'string' &&
|
||||||
|
'data' in e &&
|
||||||
|
typeof e.data === 'object' &&
|
||||||
|
e.data !== null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
|||||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||||
import { cn } from '../lib/cn'
|
import { cn } from '../lib/cn'
|
||||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||||
import { transformCase } from '../lib/strings'
|
|
||||||
|
|
||||||
import MyPicture from './MyPicture.astro'
|
import MyPicture from './MyPicture.astro'
|
||||||
import Tooltip from './Tooltip.astro'
|
import Tooltip from './Tooltip.astro'
|
||||||
@@ -14,7 +13,7 @@ import Tooltip from './Tooltip.astro'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLAttributes } from 'astro/types'
|
||||||
|
|
||||||
type Props = HTMLAttributes<'a'> & {
|
type Props = HTMLAttributes<'article'> & {
|
||||||
inlineIcons?: boolean
|
inlineIcons?: boolean
|
||||||
withoutLink?: boolean
|
withoutLink?: boolean
|
||||||
service: Prisma.ServiceGetPayload<{
|
service: Prisma.ServiceGetPayload<{
|
||||||
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
|
|||||||
slug: true
|
slug: true
|
||||||
description: true
|
description: true
|
||||||
overallScore: true
|
overallScore: true
|
||||||
|
privacyScore: true
|
||||||
|
trustScore: true
|
||||||
kycLevel: true
|
kycLevel: true
|
||||||
imageUrl: true
|
imageUrl: true
|
||||||
verificationStatus: true
|
verificationStatus: true
|
||||||
@@ -45,6 +46,8 @@ const {
|
|||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
overallScore,
|
overallScore,
|
||||||
|
privacyScore,
|
||||||
|
trustScore,
|
||||||
kycLevel,
|
kycLevel,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories,
|
categories,
|
||||||
@@ -54,7 +57,7 @@ const {
|
|||||||
},
|
},
|
||||||
class: className,
|
class: className,
|
||||||
withoutLink = false,
|
withoutLink = false,
|
||||||
...aProps
|
...htmlProps
|
||||||
} = Astro.props
|
} = Astro.props
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
@@ -67,127 +70,129 @@ const Element = withoutLink ? 'div' : 'a'
|
|||||||
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||||
---
|
---
|
||||||
|
|
||||||
<Element
|
<article {...htmlProps}>
|
||||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
<Element
|
||||||
{...aProps}
|
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||||
class={cn(
|
aria-label={Element === 'a' ? name : undefined}
|
||||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
className
|
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||||
)}
|
className
|
||||||
>
|
)}
|
||||||
<!-- Header with Icon and Title -->
|
>
|
||||||
<div class="flex items-center gap-(--gap)">
|
<!-- Header with Icon and Title -->
|
||||||
<MyPicture
|
<div class="flex items-center gap-(--gap)">
|
||||||
src={imageUrl}
|
<MyPicture
|
||||||
fallback="service"
|
src={imageUrl}
|
||||||
alt={name || 'Service logo'}
|
fallback="service"
|
||||||
class={cn(
|
alt="Logo"
|
||||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
class={cn(
|
||||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||||
)}
|
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||||
width={48}
|
)}
|
||||||
height={48}
|
width={48}
|
||||||
/>
|
height={48}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
<div class="flex min-w-0 flex-1 flex-col justify-center self-stretch">
|
||||||
<h3 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
<h1 class="font-title text-lg leading-none font-medium tracking-wide text-white">
|
||||||
{name}{
|
{name}{
|
||||||
statusIcon && (
|
statusIcon && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text={statusIcon.label}
|
text={statusIcon.label}
|
||||||
position="right"
|
position="right"
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
enabled={verificationStatus !== 'VERIFICATION_FAILED'}
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
|
<Icon
|
||||||
|
is:inline={inlineIcons}
|
||||||
|
name={statusIcon.icon}
|
||||||
|
class={cn(
|
||||||
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
|
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
||||||
|
statusIcon.classNames.icon
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
verificationStatus === 'VERIFICATION_FAILED' && (
|
||||||
|
<span class="text-sm font-bold text-red-500">SCAM</span>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}{
|
||||||
|
serviceVisibility === 'ARCHIVED' && (
|
||||||
|
<Tooltip
|
||||||
|
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||||
|
position="right"
|
||||||
|
class="-my-2 shrink-0 whitespace-nowrap"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
is:inline={inlineIcons}
|
is:inline={inlineIcons}
|
||||||
name={statusIcon.icon}
|
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||||
class={cn(
|
class={cn(
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && 'pr-0',
|
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||||
statusIcon.classNames.icon
|
|
||||||
)}
|
)}
|
||||||
/>,
|
/>
|
||||||
verificationStatus === 'VERIFICATION_FAILED' && (
|
</Tooltip>
|
||||||
<span class="text-sm font-bold text-red-500">SCAM</span>
|
)
|
||||||
),
|
}
|
||||||
]}
|
</h1>
|
||||||
</Tooltip>
|
<div class="max-h-2 flex-1" aria-hidden="true"></div>
|
||||||
)
|
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
||||||
}{
|
{
|
||||||
serviceVisibility === 'ARCHIVED' && (
|
categories.map((category) => (
|
||||||
<Tooltip
|
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
||||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
||||||
position="right"
|
<span>{category.name}</span>
|
||||||
class="-my-2 shrink-0 whitespace-nowrap"
|
</span>
|
||||||
>
|
))
|
||||||
<Icon
|
}
|
||||||
is:inline={inlineIcons}
|
</div>
|
||||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
</div>
|
||||||
class={cn(
|
</div>
|
||||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
|
||||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
<div class="flex-1">
|
||||||
)}
|
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
||||||
/>
|
{description}
|
||||||
</Tooltip>
|
</p>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
</h3>
|
<div class="flex items-center justify-start">
|
||||||
<div class="max-h-2 flex-1"></div>
|
<Tooltip
|
||||||
<div class="flex items-center gap-4 overflow-hidden mask-r-from-[calc(100%-var(--spacing)*4)]">
|
class={cn(
|
||||||
|
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
||||||
|
overallScoreInfo.classNameBg
|
||||||
|
)}
|
||||||
|
text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
|
||||||
|
>
|
||||||
|
{overallScoreInfo.formattedScore}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
||||||
|
KYC {kycLevel.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="-m-1 ml-auto flex">
|
||||||
{
|
{
|
||||||
categories.map((category) => (
|
currencies.map((currency) => {
|
||||||
<span class="text-day-300 inline-flex shrink-0 items-center gap-1 text-sm leading-none">
|
const isAccepted = acceptedCurrencies.includes(currency.id)
|
||||||
<Icon name={category.icon} class="size-4" is:inline={inlineIcons} />
|
|
||||||
<span>{category.name}</span>
|
return (
|
||||||
</span>
|
<Tooltip text={currency.name}>
|
||||||
))
|
<Icon
|
||||||
|
is:inline={inlineIcons}
|
||||||
|
name={currency.icon}
|
||||||
|
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Element>
|
||||||
|
</article>
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-day-400 line-clamp-3 text-sm leading-tight">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-start">
|
|
||||||
<Tooltip
|
|
||||||
class={cn(
|
|
||||||
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
|
|
||||||
overallScoreInfo.classNameBg
|
|
||||||
)}
|
|
||||||
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`}
|
|
||||||
>
|
|
||||||
{overallScoreInfo.formattedScore}
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<span class="text-day-300 ml-3 text-sm font-bold whitespace-nowrap">
|
|
||||||
KYC {kycLevel.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="-m-1 ml-auto flex">
|
|
||||||
{
|
|
||||||
currencies.map((currency) => {
|
|
||||||
const isAccepted = acceptedCurrencies.includes(currency.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip text={currency.name}>
|
|
||||||
<Icon
|
|
||||||
is:inline={inlineIcons}
|
|
||||||
name={currency.icon}
|
|
||||||
class={cn('text-day-600 box-content size-4 p-1', { 'text-white': isAccepted })}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Element>
|
|
||||||
|
|||||||