Update pricing UX, billing flows, and API rules
This commit is contained in:
61
admin-dashboard-plan.md
Normal file
61
admin-dashboard-plan.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Admin Dashboard Plan (Power Control)
|
||||||
|
|
||||||
|
This is the internal control panel used to keep Dewemoji **safe, clean, and reliable**.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Moderate public keywords and votes.
|
||||||
|
- Manage licenses and activations.
|
||||||
|
- Monitor system health and data pipelines.
|
||||||
|
|
||||||
|
## Phase 1 (MVP — must‑have)
|
||||||
|
|
||||||
|
### 1) Public keyword moderation
|
||||||
|
- View **public_pending** keyword queue.
|
||||||
|
- Approve / reject / block keyword.
|
||||||
|
- See emoji, language, proposer, vote counts.
|
||||||
|
|
||||||
|
### 2) Abuse controls
|
||||||
|
- Blocklist terms.
|
||||||
|
- Quick “hide” keyword from public search.
|
||||||
|
- Soft‑ban repeated abusive accounts.
|
||||||
|
|
||||||
|
### 3) License management
|
||||||
|
- Lookup by license key.
|
||||||
|
- See activations (device_id, product).
|
||||||
|
- Revoke activation or whole license.
|
||||||
|
|
||||||
|
### 4) System health
|
||||||
|
- Last JSON rebuild time.
|
||||||
|
- Dataset counts (emojis, keywords).
|
||||||
|
- API usage summary (daily).
|
||||||
|
|
||||||
|
### 5) Price control (Personal plan)
|
||||||
|
- Set IDR pricing for Monthly / Annual / Lifetime.
|
||||||
|
- Optional USD display override (approx only).
|
||||||
|
- Toggle payment rails (PayPal / QRIS).
|
||||||
|
- Effective date + change log (who changed, when).
|
||||||
|
|
||||||
|
## Phase 2 (Nice‑to‑have)
|
||||||
|
|
||||||
|
- AI moderation log viewer.
|
||||||
|
- Turnstile failure analytics.
|
||||||
|
- Contributor leaderboard.
|
||||||
|
- Email queue status.
|
||||||
|
- Scheduled job history.
|
||||||
|
- Pricing experiment history.
|
||||||
|
|
||||||
|
## Suggested navigation
|
||||||
|
|
||||||
|
- **Dashboard** (health, quick stats)
|
||||||
|
- **Keywords** (pending + public)
|
||||||
|
- **Licenses**
|
||||||
|
- **Users**
|
||||||
|
- **System** (jobs, JSON rebuild, logs)
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
- Admin login uses **magic‑link/OTP session** + **role=admin** check.
|
||||||
|
- `X-Admin-Token` is **dev/internal only** (disable in prod).
|
||||||
|
- No IP allowlist required (dynamic ISP friendly).
|
||||||
|
- Log all actions (who approved / rejected / revoked).
|
||||||
355
api-how-it-works.md
Normal file
355
api-how-it-works.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
# Dewemoji API — How It Works
|
||||||
|
|
||||||
|
This document explains the current API surface in the rebuild app, including filters, headers, and request/response shapes.
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
|
||||||
|
- Local: `http://127.0.0.1:8000/v1`
|
||||||
|
- Staging: `https://dewemoji.backoffice.biz.id/v1`
|
||||||
|
|
||||||
|
## Auth & headers
|
||||||
|
|
||||||
|
### License key (optional for free, required for Pro)
|
||||||
|
You can send a license key in either header:
|
||||||
|
|
||||||
|
- `Authorization: Bearer YOUR_LICENSE_KEY` (recommended)
|
||||||
|
- `X-License-Key: YOUR_LICENSE_KEY` (also supported)
|
||||||
|
|
||||||
|
### Optional headers
|
||||||
|
- `X-Account-Id`: Optional usage association.
|
||||||
|
- `X-Dewemoji-Frontend`: Optional frontend identifier (string).
|
||||||
|
|
||||||
|
### Response headers you’ll see
|
||||||
|
- `X-Dewemoji-Tier`: `free` or `pro`
|
||||||
|
- `X-Dewemoji-Plan`: `free` or `pro`
|
||||||
|
- Rate‑limit headers on free tier (page=1 only):
|
||||||
|
- `X-RateLimit-Limit`
|
||||||
|
- `X-RateLimit-Remaining`
|
||||||
|
- `X-RateLimit-Reset`
|
||||||
|
- Caching:
|
||||||
|
- `ETag`
|
||||||
|
- `Cache-Control`
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET `/emojis`
|
||||||
|
Search emojis with filters.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `q` (string): search query (also accepts `query`)
|
||||||
|
- `category` (string): category name (e.g. `Smileys & Emotion`)
|
||||||
|
- `subcategory` (string): subcategory name (will be slugified internally)
|
||||||
|
- `page` (int, default 1)
|
||||||
|
- `limit` (int):
|
||||||
|
- Free tier: max 20
|
||||||
|
- Pro tier: max 50
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If `q` is empty, it returns all emojis (subject to pagination).
|
||||||
|
- If `q` has multiple terms, all terms must match.
|
||||||
|
- `category` must match the exact category label in the dataset.
|
||||||
|
- `subcategory` is matched by slugifying both the request and dataset.
|
||||||
|
- `plan` field in response will be `free` or `pro`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (shape):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"emoji": "😍",
|
||||||
|
"name": "smiling face with heart-eyes",
|
||||||
|
"slug": "smiling-face-with-heart-eyes",
|
||||||
|
"category": "Smileys & Emotion",
|
||||||
|
"subcategory": "face-affection",
|
||||||
|
"supports_skin_tone": false,
|
||||||
|
"summary": "...",
|
||||||
|
"unified": "U+1F60D",
|
||||||
|
"codepoints": ["1F60D"],
|
||||||
|
"shortcodes": [":smiling-face-with-heart-eyes:", "..."],
|
||||||
|
"aliases": [],
|
||||||
|
"keywords_en": ["love", "heart", "..."],
|
||||||
|
"keywords_id": ["cinta", "..."],
|
||||||
|
"related": ["🥰", "🤩", "😘"],
|
||||||
|
"intent_tags": ["love", "affection"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2131,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20,
|
||||||
|
"plan": "free"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/emoji/{slug}` or `/emoji?slug=...`
|
||||||
|
Fetch detail for a specific emoji.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/v1/emoji/grinning-face" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- `400` if slug is missing (`error: missing_slug`)
|
||||||
|
- `404` if slug is not found (`error: not_found`)
|
||||||
|
|
||||||
|
### GET `/categories`
|
||||||
|
Returns a map of category → list of subcategories.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/v1/categories" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Smileys & Emotion": ["face-smiling", "face-affection", "..."],
|
||||||
|
"People & Body": ["hand-fingers-open", "..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/license/verify`
|
||||||
|
Validate a license key.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- `Authorization: Bearer YOUR_LICENSE_KEY` (or `X-License-Key`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://127.0.0.1:8000/v1/license/verify" \
|
||||||
|
-H "Authorization: Bearer YOUR_LICENSE_KEY" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Success response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"tier": "pro",
|
||||||
|
"source": "gumroad|mayar|sandbox|...",
|
||||||
|
"plan": "pro",
|
||||||
|
"product_id": "…",
|
||||||
|
"expires_at": null,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- `400` `missing_key`
|
||||||
|
- `401` `invalid_license`
|
||||||
|
|
||||||
|
### POST `/license/activate`
|
||||||
|
Activate a device or site session.
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "you@example.com",
|
||||||
|
"product": "extension|site",
|
||||||
|
"device_id": "device-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `product=site` does not require `device_id`.
|
||||||
|
- Other products require `device_id`.
|
||||||
|
|
||||||
|
### POST `/license/deactivate`
|
||||||
|
Deactivate a device.
|
||||||
|
|
||||||
|
Body (JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"product": "extension|site",
|
||||||
|
"device_id": "device-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/health`
|
||||||
|
Basic health check.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "ok": true, "time": "...", "app": "Dewemoji" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/metrics-lite`
|
||||||
|
Lightweight metrics (if enabled).
|
||||||
|
|
||||||
|
### GET `/metrics`
|
||||||
|
Full metrics (requires token or allowed IP).
|
||||||
|
|
||||||
|
## Extension verification (public, no login)
|
||||||
|
|
||||||
|
These endpoints allow **verified extension installs** to access public search without login.
|
||||||
|
|
||||||
|
Fallback (temporary):
|
||||||
|
- If Verified Access token is not available, the server can accept `X-Extension-Id`
|
||||||
|
as a **soft allowlist** signal. This is **spoofable** and should not be treated as
|
||||||
|
a strong security boundary.
|
||||||
|
|
||||||
|
### POST `/extension/verify`
|
||||||
|
Verifies `X-Extension-Token` by calling Google Instance ID API.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- `X-Extension-Token: <token>`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{ "ok": true, "verified": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/extension/search`
|
||||||
|
Public search for verified extension installs only.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- `X-Extension-Token: <token>`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:8000/v1/extension/search?q=snail" \
|
||||||
|
-H "X-Extension-Token: $EXT_TOKEN" | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors:
|
||||||
|
- `403 extension_unverified`
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
The API uses `ETag` and returns `304 Not Modified` if the client sends:
|
||||||
|
```
|
||||||
|
If-None-Match: "etag-value"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
ETAG=$(curl -i "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | awk -F': ' '/^ETag:/ {print $2}' | tr -d '\r')
|
||||||
|
curl -i -H "If-None-Match: $ETAG" "http://127.0.0.1:8000/v1/emojis?q=love&limit=5" | head -n 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public access guard (whitelist + soft throttle)
|
||||||
|
|
||||||
|
Public endpoints (`/v1/emojis`, `/v1/categories`, `/v1/emoji`) are protected by a **whitelist + hourly throttle**.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If the request is **whitelisted**, it passes without throttling.
|
||||||
|
- If not whitelisted:
|
||||||
|
- If `DEWEMOJI_PUBLIC_ENFORCE=true` → `403 public_access_denied`
|
||||||
|
- Else → **soft throttle** with `DEWEMOJI_PUBLIC_HOURLY_LIMIT` (HTTP `429 public_rate_limited`)
|
||||||
|
|
||||||
|
Whitelist rules:
|
||||||
|
- `Origin` is in `DEWEMOJI_PUBLIC_ORIGINS`
|
||||||
|
- Or `X-Dewemoji-Frontend` / `User-Agent` contains a configured extension ID
|
||||||
|
|
||||||
|
Key env vars:
|
||||||
|
```
|
||||||
|
DEWEMOJI_PUBLIC_ENFORCE=true|false
|
||||||
|
DEWEMOJI_PUBLIC_ORIGINS=https://dewemoji.com,https://www.dewemoji.com
|
||||||
|
DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000
|
||||||
|
DEWEMOJI_EXTENSION_IDS=chrome-extension://...
|
||||||
|
```
|
||||||
|
|
||||||
|
Rate limit response example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "public_rate_limited",
|
||||||
|
"usage": {
|
||||||
|
"used": 3,
|
||||||
|
"limit": 3,
|
||||||
|
"remaining": 0,
|
||||||
|
"window": "hourly",
|
||||||
|
"window_ends_at": "2026-02-08T14:00:00+00:00",
|
||||||
|
"window_ends_at_unix": 1770559200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin endpoints (token required)
|
||||||
|
|
||||||
|
All admin endpoints require:
|
||||||
|
```
|
||||||
|
X-Admin-Token: <DEWEMOJI_ADMIN_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Settings (feature flags / public access)
|
||||||
|
- `GET /v1/admin/settings`
|
||||||
|
- `POST /v1/admin/settings`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://127.0.0.1:8000/v1/admin/settings" \
|
||||||
|
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"settings": {
|
||||||
|
"maintenance_enabled": false,
|
||||||
|
"public_enforce": true,
|
||||||
|
"public_hourly_limit": 5000,
|
||||||
|
"public_origins": ["https://dewemoji.com","https://www.dewemoji.com"],
|
||||||
|
"public_extension_ids": ["chrome-extension://yourid"]
|
||||||
|
}
|
||||||
|
}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscriptions (admin grant/revoke)
|
||||||
|
- `GET /v1/admin/subscriptions`
|
||||||
|
- `POST /v1/admin/subscription/grant`
|
||||||
|
- `POST /v1/admin/subscription/revoke`
|
||||||
|
|
||||||
|
Grant example:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://127.0.0.1:8000/v1/admin/subscription/grant" \
|
||||||
|
-H "X-Admin-Token: $DEWEMOJI_ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@example.com","plan":"personal","status":"active","provider":"admin"}' | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhooks (PayPal)
|
||||||
|
- `POST /v1/paypal/webhook` (logs + processes)
|
||||||
|
- `GET /v1/admin/webhooks`
|
||||||
|
- `GET /v1/admin/webhooks/{id}`
|
||||||
|
- `POST /v1/admin/webhooks/{id}/replay`
|
||||||
|
|
||||||
|
Deduping:
|
||||||
|
- PayPal `id` is stored as `event_id`. Duplicate events return `{ "ok": true, "duplicate": true }`.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- PayPal **signature verification is not implemented yet** (TODO before production).
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- `GET /v1/admin/analytics` (counts for users/keywords/subscriptions/webhooks)
|
||||||
|
|
||||||
|
## Security note: Origin/Referer are spoofable
|
||||||
|
|
||||||
|
The `Origin` / `Referer` headers are **not** a strong security boundary. They are used to reduce casual abuse only.
|
||||||
|
|
||||||
|
Options to harden public access:
|
||||||
|
- **Require API keys** for unlimited access (personal users).
|
||||||
|
- **Rate limit at the edge** (Cloudflare / Nginx).
|
||||||
|
- **Signed requests** for extension (HMAC or short‑lived tokens).
|
||||||
|
- **Shared secret header** for internal services.
|
||||||
|
- **WAF rules** for `/v1/*` endpoints.
|
||||||
|
|
||||||
|
## Error payloads (common)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "error": "not_found" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Other possible errors:
|
||||||
|
- `missing_slug`
|
||||||
|
- `missing_key`
|
||||||
|
- `invalid_license`
|
||||||
|
- `daily_limit_reached`
|
||||||
|
- `data_load_failed`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Current dataset contains **EN + ID** keywords.
|
||||||
|
- API reads from `app/data/emojis.json` (cache-first strategy).
|
||||||
|
- Free tier limit is enforced on **page=1** requests.
|
||||||
@@ -47,7 +47,7 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=mailketing
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
MAIL_PORT=2525
|
MAIL_PORT=2525
|
||||||
@@ -55,6 +55,9 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
MAILKETING_API_URL=https://api.mailketing.co.id/api/v1/send
|
||||||
|
MAILKETING_API_TOKEN=
|
||||||
|
MAILKETING_TIMEOUT=10
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
@@ -95,3 +98,27 @@ DEWEMOJI_FRONTEND_HEADER=web-v1
|
|||||||
DEWEMOJI_METRICS_ENABLED=true
|
DEWEMOJI_METRICS_ENABLED=true
|
||||||
DEWEMOJI_METRICS_TOKEN=
|
DEWEMOJI_METRICS_TOKEN=
|
||||||
DEWEMOJI_METRICS_ALLOW_IPS=127.0.0.1,::1
|
DEWEMOJI_METRICS_ALLOW_IPS=127.0.0.1,::1
|
||||||
|
DEWEMOJI_USD_RATE=15000
|
||||||
|
DEWEMOJI_QRIS_URL=
|
||||||
|
DEWEMOJI_PAYPAL_URL=
|
||||||
|
DEWEMOJI_PAYPAL_ENABLED=false
|
||||||
|
DEWEMOJI_PAYPAL_TIMEOUT=10
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID=
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_CLIENT_SECRET=
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_WEBHOOK_ID=
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_MONTHLY=
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_ANNUAL=
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_CLIENT_ID=
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_CLIENT_SECRET=
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_WEBHOOK_ID=
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_MONTHLY=
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_ANNUAL=
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_API_BASE=https://api-m.sandbox.paypal.com
|
||||||
|
DEWEMOJI_PAYPAL_SANDBOX_WEB_BASE=https://www.sandbox.paypal.com
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_API_BASE=https://api-m.paypal.com
|
||||||
|
DEWEMOJI_PAYPAL_LIVE_WEB_BASE=https://www.paypal.com
|
||||||
|
DEWEMOJI_PAKASIR_ENABLED=false
|
||||||
|
DEWEMOJI_PAKASIR_API_BASE=https://app.pakasir.com
|
||||||
|
DEWEMOJI_PAKASIR_API_KEY=
|
||||||
|
DEWEMOJI_PAKASIR_PROJECT=
|
||||||
|
DEWEMOJI_PAKASIR_TIMEOUT=10
|
||||||
|
|||||||
56
app/app/Http/Controllers/Api/V1/AdminAnalyticsController.php
Normal file
56
app/app/Http/Controllers/Api/V1/AdminAnalyticsController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use App\Models\UserKeyword;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$token = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overview(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalUsers = User::count();
|
||||||
|
$personalUsers = User::where('tier', 'personal')->count();
|
||||||
|
$apiKeys = UserApiKey::count();
|
||||||
|
$keywords = UserKeyword::count();
|
||||||
|
$subscriptions = Subscription::count();
|
||||||
|
$activeSubscriptions = Subscription::where('status', 'active')->count();
|
||||||
|
$pricingPlans = PricingPlan::count();
|
||||||
|
$webhooks = WebhookEvent::count();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'metrics' => [
|
||||||
|
'users_total' => $totalUsers,
|
||||||
|
'users_personal' => $personalUsers,
|
||||||
|
'api_keys_total' => $apiKeys,
|
||||||
|
'keywords_total' => $keywords,
|
||||||
|
'subscriptions_total' => $subscriptions,
|
||||||
|
'subscriptions_active' => $activeSubscriptions,
|
||||||
|
'pricing_plans_total' => $pricingPlans,
|
||||||
|
'webhook_events_total' => $webhooks,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/app/Http/Controllers/Api/V1/AdminPricingController.php
Normal file
141
app/app/Http/Controllers/Api/V1/AdminPricingController.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PricingChange;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class AdminPricingController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$adminToken = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plans = PricingPlan::orderBy('id')->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'plans' => $plans,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changes(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = max((int) $request->query('limit', 20), 1);
|
||||||
|
$items = PricingChange::orderByDesc('id')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'admin_ref' => 'nullable|string|max:120',
|
||||||
|
'plans' => 'required|array|min:1',
|
||||||
|
'plans.*.code' => 'required|string|max:30',
|
||||||
|
'plans.*.name' => 'required|string|max:50',
|
||||||
|
'plans.*.currency' => 'required|string|max:10',
|
||||||
|
'plans.*.amount' => 'required|integer|min:0',
|
||||||
|
'plans.*.period' => 'nullable|string|max:20',
|
||||||
|
'plans.*.status' => 'nullable|string|max:20',
|
||||||
|
'plans.*.meta' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$before = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data): void {
|
||||||
|
foreach ($data['plans'] as $plan) {
|
||||||
|
PricingPlan::updateOrCreate(
|
||||||
|
['code' => $plan['code']],
|
||||||
|
[
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'currency' => $plan['currency'],
|
||||||
|
'amount' => $plan['amount'],
|
||||||
|
'period' => $plan['period'] ?? null,
|
||||||
|
'status' => $plan['status'] ?? 'active',
|
||||||
|
'meta' => $plan['meta'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
PricingChange::create([
|
||||||
|
'admin_ref' => $data['admin_ref'] ?? null,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'plans' => $after,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = config('dewemoji.pricing.defaults', []);
|
||||||
|
$before = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($defaults): void {
|
||||||
|
PricingPlan::query()->delete();
|
||||||
|
foreach ($defaults as $plan) {
|
||||||
|
PricingPlan::create([
|
||||||
|
'code' => $plan['code'],
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'currency' => $plan['currency'],
|
||||||
|
'amount' => $plan['amount'],
|
||||||
|
'period' => $plan['period'],
|
||||||
|
'status' => $plan['status'],
|
||||||
|
'meta' => $plan['meta'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
PricingChange::create([
|
||||||
|
'admin_ref' => (string) $request->header('X-Admin-Ref', ''),
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'plans' => $after,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/app/Http/Controllers/Api/V1/AdminSettingsController.php
Normal file
61
app/app/Http/Controllers/Api/V1/AdminSettingsController.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\System\SettingsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminSettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SettingsService $settings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$token = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = $request->query('keys');
|
||||||
|
$all = $this->settings->all();
|
||||||
|
if (is_string($keys) && $keys !== '') {
|
||||||
|
$filter = array_filter(array_map('trim', explode(',', $keys)));
|
||||||
|
$all = array_intersect_key($all, array_flip($filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'settings' => $all]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->input('settings');
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'invalid_payload'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminRef = (string) $request->header('X-Admin-Ref', '');
|
||||||
|
$this->settings->setMany($payload, $adminRef !== '' ? $adminRef : null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'settings' => $this->settings->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php
Normal file
158
app/app/Http/Controllers/Api/V1/AdminSubscriptionController.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class AdminSubscriptionController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$token = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = Subscription::query()->with('user:id,email,name,tier');
|
||||||
|
|
||||||
|
if ($userId = $request->query('user_id')) {
|
||||||
|
$query->where('user_id', (int) $userId);
|
||||||
|
}
|
||||||
|
if ($email = $request->query('email')) {
|
||||||
|
$query->whereHas('user', fn ($q) => $q->where('email', $email));
|
||||||
|
}
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min(max((int) $request->query('limit', 50), 1), 200);
|
||||||
|
$items = $query->orderByDesc('id')->limit($limit)->get();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grant(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = (string) $request->input('plan', 'personal');
|
||||||
|
$status = (string) $request->input('status', 'active');
|
||||||
|
$provider = (string) $request->input('provider', 'admin');
|
||||||
|
$providerRef = (string) $request->input('provider_ref', '');
|
||||||
|
$startedAt = $this->parseDate($request->input('started_at')) ?? now();
|
||||||
|
$expiresAt = $this->parseDate($request->input('expires_at'));
|
||||||
|
|
||||||
|
$sub = Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan' => $plan,
|
||||||
|
'status' => $status,
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_ref' => $providerRef !== '' ? $providerRef : null,
|
||||||
|
'started_at' => $startedAt,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === 'active') {
|
||||||
|
$user->update(['tier' => 'personal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'subscription' => $sub]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $request->input('id', 0);
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$sub = Subscription::find($id);
|
||||||
|
if (!$sub) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
$sub->update(['status' => 'revoked', 'expires_at' => $now]);
|
||||||
|
$this->syncUserTier($sub->user_id);
|
||||||
|
return response()->json(['ok' => true, 'revoked' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->update(['status' => 'revoked', 'expires_at' => $now]);
|
||||||
|
|
||||||
|
$this->syncUserTier($user->id);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'revoked' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveUser(Request $request): ?User
|
||||||
|
{
|
||||||
|
if ($userId = $request->input('user_id')) {
|
||||||
|
return User::find((int) $userId);
|
||||||
|
}
|
||||||
|
if ($email = $request->input('email')) {
|
||||||
|
return User::where('email', (string) $email)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDate(mixed $value): ?Carbon
|
||||||
|
{
|
||||||
|
if (!$value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Carbon::parse((string) $value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncUserTier(int $userId): void
|
||||||
|
{
|
||||||
|
$active = Subscription::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where(function ($q): void {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
User::where('id', $userId)->update([
|
||||||
|
'tier' => $active ? 'personal' : 'free',
|
||||||
|
]);
|
||||||
|
if (!$active) {
|
||||||
|
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/app/Http/Controllers/Api/V1/AdminUserController.php
Normal file
123
app/app/Http/Controllers/Api/V1/AdminUserController.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminUserController extends Controller
|
||||||
|
{
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$adminToken = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($adminToken === '' || $provided === '' || !hash_equals($adminToken, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$limit = max((int) $request->query('limit', 20), 1);
|
||||||
|
|
||||||
|
$query = User::query()->orderByDesc('id');
|
||||||
|
if ($q !== '') {
|
||||||
|
$query->where(function ($sub) use ($q): void {
|
||||||
|
$sub->where('email', 'like', '%'.$q.'%')
|
||||||
|
->orWhere('name', 'like', '%'.$q.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $query->limit($limit)->get(['id', 'name', 'email', 'tier', 'created_at']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim((string) $request->query('email', ''));
|
||||||
|
$userId = (int) $request->query('user_id', 0);
|
||||||
|
|
||||||
|
$query = User::query();
|
||||||
|
if ($email !== '') {
|
||||||
|
$query->where('email', $email);
|
||||||
|
} elseif ($userId > 0) {
|
||||||
|
$query->where('id', $userId);
|
||||||
|
} else {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $query->first();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'tier' => $user->tier,
|
||||||
|
'created_at' => $user->created_at,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTier(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'user_id' => 'nullable|integer',
|
||||||
|
'tier' => 'required|string|in:free,personal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = User::query();
|
||||||
|
if (!empty($data['email'])) {
|
||||||
|
$query->where('email', $data['email']);
|
||||||
|
} elseif (!empty($data['user_id'])) {
|
||||||
|
$query->where('id', $data['user_id']);
|
||||||
|
} else {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'missing_target'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = $query->first();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->tier = $data['tier'];
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'tier' => $user->tier,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/app/Http/Controllers/Api/V1/AdminWebhookController.php
Normal file
92
app/app/Http/Controllers/Api/V1/AdminWebhookController.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use App\Services\Billing\PaypalWebhookProcessor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PaypalWebhookProcessor $processor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$token = (string) config('dewemoji.admin.token', '');
|
||||||
|
$provided = trim((string) $request->header('X-Admin-Token', ''));
|
||||||
|
if ($token === '' || $provided === '' || !hash_equals($token, $provided)) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = WebhookEvent::query();
|
||||||
|
if ($provider = $request->query('provider')) {
|
||||||
|
$query->where('provider', (string) $provider);
|
||||||
|
}
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = min(max((int) $request->query('limit', 50), 1), 200);
|
||||||
|
$items = $query->orderByDesc('id')->limit($limit)->get();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = WebhookEvent::find($id);
|
||||||
|
if (!$item) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'item' => $item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replay(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
if ($res = $this->authorizeAdmin($request)) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = WebhookEvent::find($id);
|
||||||
|
if (!$item) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($item->provider === 'paypal') {
|
||||||
|
$this->processor->process((string) ($item->event_type ?? ''), $item->payload ?? []);
|
||||||
|
}
|
||||||
|
$item->update([
|
||||||
|
'status' => 'processed',
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$item->update([
|
||||||
|
'status' => 'error',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'replayed' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Services\Billing\LicenseVerificationService;
|
use App\Services\Billing\LicenseVerificationService;
|
||||||
|
use App\Services\Auth\ApiKeyService;
|
||||||
|
use App\Services\System\SettingsService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -21,7 +23,8 @@ class EmojiApiController extends Controller
|
|||||||
private static ?array $dataset = null;
|
private static ?array $dataset = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LicenseVerificationService $verification
|
private readonly LicenseVerificationService $verification,
|
||||||
|
private readonly ApiKeyService $apiKeys
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,10 @@ class EmojiApiController extends Controller
|
|||||||
|
|
||||||
public function categories(Request $request): JsonResponse
|
public function categories(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
if ($blocked = $this->enforcePublicAccess($request)) {
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
$tier = $this->detectTier($request);
|
$tier = $this->detectTier($request);
|
||||||
try {
|
try {
|
||||||
$data = $this->loadData();
|
$data = $this->loadData();
|
||||||
@@ -87,6 +94,10 @@ class EmojiApiController extends Controller
|
|||||||
|
|
||||||
public function emojis(Request $request): JsonResponse
|
public function emojis(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
if ($blocked = $this->enforcePublicAccess($request)) {
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
$tier = $this->detectTier($request);
|
$tier = $this->detectTier($request);
|
||||||
try {
|
try {
|
||||||
$data = $this->loadData();
|
$data = $this->loadData();
|
||||||
@@ -109,45 +120,7 @@ class EmojiApiController extends Controller
|
|||||||
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
|
: max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
|
||||||
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
||||||
|
|
||||||
$filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
|
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
||||||
$itemCategory = trim((string) ($item['category'] ?? ''));
|
|
||||||
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
|
|
||||||
|
|
||||||
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ($q === '') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$haystack = strtolower(implode(' ', [
|
|
||||||
(string) ($item['emoji'] ?? ''),
|
|
||||||
(string) ($item['name'] ?? ''),
|
|
||||||
(string) ($item['slug'] ?? ''),
|
|
||||||
$itemCategory,
|
|
||||||
$itemSubcategory,
|
|
||||||
implode(' ', $item['keywords_en'] ?? []),
|
|
||||||
implode(' ', $item['keywords_id'] ?? []),
|
|
||||||
implode(' ', $item['aliases'] ?? []),
|
|
||||||
implode(' ', $item['shortcodes'] ?? []),
|
|
||||||
implode(' ', $item['alt_shortcodes'] ?? []),
|
|
||||||
implode(' ', $item['intent_tags'] ?? []),
|
|
||||||
]));
|
|
||||||
|
|
||||||
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
|
|
||||||
foreach ($tokens as $token) {
|
|
||||||
if ($token === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!str_contains($haystack, $token)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}));
|
|
||||||
|
|
||||||
$total = count($filtered);
|
$total = count($filtered);
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
@@ -170,44 +143,123 @@ class EmojiApiController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tier === self::TIER_FREE && $page === 1) {
|
|
||||||
$usage = $this->trackDailyUsage($request, $q, $category, $subSlug);
|
|
||||||
if ($usage['blocked']) {
|
|
||||||
return $this->jsonWithTier($request, [
|
|
||||||
'ok' => false,
|
|
||||||
'error' => 'daily_limit_reached',
|
|
||||||
'message' => 'Daily free limit reached. Upgrade to Pro for unlimited usage.',
|
|
||||||
'plan' => self::TIER_FREE,
|
|
||||||
'usage' => $usage['meta'],
|
|
||||||
], $tier, 429, [
|
|
||||||
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
|
|
||||||
'X-RateLimit-Remaining' => '0',
|
|
||||||
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
|
|
||||||
'ETag' => $etag,
|
|
||||||
'Cache-Control' => 'public, max-age=120',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$responsePayload['plan'] = self::TIER_FREE;
|
|
||||||
$responsePayload['usage'] = $usage['meta'];
|
|
||||||
|
|
||||||
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
|
|
||||||
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
|
|
||||||
'X-RateLimit-Remaining' => (string) $usage['meta']['remaining'],
|
|
||||||
'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'),
|
|
||||||
'ETag' => $etag,
|
|
||||||
'Cache-Control' => 'public, max-age=120',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
|
return $this->jsonWithTier($request, $responsePayload, $tier, 200, [
|
||||||
'ETag' => $etag,
|
'ETag' => $etag,
|
||||||
'Cache-Control' => 'public, max-age=120',
|
'Cache-Control' => 'public, max-age=120',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$private = filter_var($request->query('private', false), FILTER_VALIDATE_BOOL);
|
||||||
|
if (!$private) {
|
||||||
|
return $this->emojis($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
if ((string) $user->tier !== 'personal') {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = trim((string) ($request->query('q', $request->query('query', ''))));
|
||||||
|
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
|
||||||
|
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->loadData();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return $this->jsonWithTier($request, [
|
||||||
|
'error' => 'data_load_failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], self::TIER_FREE, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $data['emojis'] ?? [];
|
||||||
|
$itemsBySlug = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$slug = (string) ($item['slug'] ?? '');
|
||||||
|
if ($slug !== '') {
|
||||||
|
$itemsBySlug[$slug] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
||||||
|
$publicBySlug = [];
|
||||||
|
foreach ($filtered as $item) {
|
||||||
|
$slug = (string) ($item['slug'] ?? '');
|
||||||
|
if ($slug !== '') {
|
||||||
|
$publicBySlug[$slug] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$privateMatches = [];
|
||||||
|
if ($q !== '') {
|
||||||
|
$rows = DB::table('user_keywords')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%'])
|
||||||
|
->get(['id', 'emoji_slug', 'keyword', 'lang']);
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$slug = (string) $row->emoji_slug;
|
||||||
|
if ($slug === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$privateMatches[$slug] = [
|
||||||
|
'id' => (int) $row->id,
|
||||||
|
'keyword' => (string) $row->keyword,
|
||||||
|
'lang' => (string) $row->lang,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tier = self::TIER_PRO;
|
||||||
|
$merged = [];
|
||||||
|
foreach ($privateMatches as $slug => $meta) {
|
||||||
|
$sourceItem = $publicBySlug[$slug] ?? $itemsBySlug[$slug] ?? null;
|
||||||
|
if ($sourceItem === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$item = $this->transformItem($sourceItem, $tier);
|
||||||
|
$item['source'] = 'private';
|
||||||
|
$item['matched_keyword_id'] = $meta['id'] ?? null;
|
||||||
|
$item['matched_keyword'] = $meta['keyword'];
|
||||||
|
$item['matched_lang'] = $meta['lang'];
|
||||||
|
$merged[$slug] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($publicBySlug as $slug => $item) {
|
||||||
|
if (isset($merged[$slug])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$row = $this->transformItem($item, $tier);
|
||||||
|
$row['source'] = 'public';
|
||||||
|
$merged[$slug] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mergedItems = array_values($merged);
|
||||||
|
$page = max((int) $request->query('page', 1), 1);
|
||||||
|
$limit = max((int) $request->query('limit', 20), 1);
|
||||||
|
$total = count($mergedItems);
|
||||||
|
$pageItems = array_slice($mergedItems, ($page - 1) * $limit, $limit);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => $pageItems,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => 'personal',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function emoji(Request $request, ?string $slug = null): JsonResponse
|
public function emoji(Request $request, ?string $slug = null): JsonResponse
|
||||||
{
|
{
|
||||||
|
if ($blocked = $this->enforcePublicAccess($request)) {
|
||||||
|
return $blocked;
|
||||||
|
}
|
||||||
|
|
||||||
$tier = $this->detectTier($request);
|
$tier = $this->detectTier($request);
|
||||||
$slug = trim((string) ($slug ?? $request->query('slug', '')));
|
$slug = trim((string) ($slug ?? $request->query('slug', '')));
|
||||||
if ($slug === '') {
|
if ($slug === '') {
|
||||||
@@ -243,6 +295,19 @@ class EmojiApiController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payload = $this->transformEmojiDetail($match, $tier);
|
$payload = $this->transformEmojiDetail($match, $tier);
|
||||||
|
$includeUserKeywords = filter_var($request->query('include_user_keywords', false), FILTER_VALIDATE_BOOL);
|
||||||
|
if ($includeUserKeywords) {
|
||||||
|
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
|
||||||
|
if ($user && (string) $user->tier === 'personal') {
|
||||||
|
$payload['user_keywords'] = DB::table('user_keywords')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $slug)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['id', 'keyword', 'lang']);
|
||||||
|
} else {
|
||||||
|
$payload['user_keywords'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
$etag = '"'.sha1(json_encode([$payload, $tier])).'"';
|
$etag = '"'.sha1(json_encode([$payload, $tier])).'"';
|
||||||
if ($this->isNotModified($request, $etag)) {
|
if ($this->isNotModified($request, $etag)) {
|
||||||
return $this->jsonWithTier($request, [], $tier, 304, [
|
return $this->jsonWithTier($request, [], $tier, 304, [
|
||||||
@@ -277,6 +342,148 @@ class EmojiApiController extends Controller
|
|||||||
return self::TIER_FREE;
|
return self::TIER_FREE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function enforcePublicAccess(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
if ($this->hasApiKeyAuth($request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = config('dewemoji.public_access', []);
|
||||||
|
$settings = app(SettingsService::class);
|
||||||
|
$maintenanceEnabled = (bool) $settings->get('maintenance_enabled', false);
|
||||||
|
if ($maintenanceEnabled) {
|
||||||
|
return $this->jsonWithTier($request, [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'maintenance',
|
||||||
|
], self::TIER_FREE, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$enforceWhitelist = (bool) $settings->get('public_enforce', $config['enforce_whitelist'] ?? false);
|
||||||
|
$allowedOrigins = $settings->get('public_origins', $config['allowed_origins'] ?? []);
|
||||||
|
$extensionIds = $settings->get('public_extension_ids', $config['extension_ids'] ?? []);
|
||||||
|
$limitOverride = $settings->get('public_hourly_limit', $config['hourly_limit'] ?? 0);
|
||||||
|
|
||||||
|
$allowed = $this->isPublicAllowed($request, [
|
||||||
|
'allowed_origins' => $allowedOrigins,
|
||||||
|
'extension_ids' => $extensionIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($enforceWhitelist && !$allowed) {
|
||||||
|
return $this->jsonWithTier($request, [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'public_access_denied',
|
||||||
|
], self::TIER_FREE, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$allowed) {
|
||||||
|
$limit = max((int) $limitOverride, 0);
|
||||||
|
if ($limit > 0) {
|
||||||
|
$usage = $this->trackPublicHourlyUsage($request, $limit);
|
||||||
|
if ($usage['blocked']) {
|
||||||
|
return $this->jsonWithTier($request, [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'public_rate_limited',
|
||||||
|
'usage' => $usage['meta'],
|
||||||
|
], self::TIER_FREE, 429, [
|
||||||
|
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
'X-RateLimit-Reset' => (string) $usage['meta']['window_ends_at_unix'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private function isPublicAllowed(Request $request, array $config): bool
|
||||||
|
{
|
||||||
|
if (app()->environment(['local', 'testing'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = trim((string) $request->getHost());
|
||||||
|
if (in_array($host, ['127.0.0.1', 'localhost'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = trim((string) $request->headers->get('Origin', ''));
|
||||||
|
$allowedOrigins = $config['allowed_origins'] ?? [];
|
||||||
|
if ($origin !== '' && is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$frontendHeader = trim((string) $request->header('X-Dewemoji-Frontend', ''));
|
||||||
|
$extensionIds = $config['extension_ids'] ?? [];
|
||||||
|
if ($frontendHeader !== '' && is_array($extensionIds)) {
|
||||||
|
foreach ($extensionIds as $id) {
|
||||||
|
if ($id !== '' && str_contains($frontendHeader, $id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensionHeader = trim((string) $request->header('X-Extension-Id', ''));
|
||||||
|
if ($extensionHeader !== '' && is_array($extensionIds)) {
|
||||||
|
if (in_array($extensionHeader, $extensionIds, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userAgent = (string) $request->userAgent();
|
||||||
|
if ($userAgent !== '' && is_array($extensionIds)) {
|
||||||
|
foreach ($extensionIds as $id) {
|
||||||
|
if ($id !== '' && str_contains($userAgent, $id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasApiKeyAuth(Request $request): bool
|
||||||
|
{
|
||||||
|
return $this->apiKeys->resolveUser($request) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{blocked:bool,meta:array<string,mixed>}
|
||||||
|
*/
|
||||||
|
private function trackPublicHourlyUsage(Request $request, int $limit): array
|
||||||
|
{
|
||||||
|
$bucket = sha1((string) $request->ip().'|'.(string) $request->userAgent());
|
||||||
|
$hourKey = Carbon::now('UTC')->format('YmdH');
|
||||||
|
$cacheKey = 'dw_public_hourly_'.$bucket.'_'.$hourKey;
|
||||||
|
$seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::now('UTC')->addHour()->startOfHour()));
|
||||||
|
|
||||||
|
$current = Cache::get($cacheKey, 0);
|
||||||
|
if (!is_int($current)) {
|
||||||
|
$current = (int) $current;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current++;
|
||||||
|
Cache::put($cacheKey, $current, $seconds);
|
||||||
|
|
||||||
|
$blocked = $current > $limit;
|
||||||
|
$windowEnds = Carbon::now('UTC')->addHour()->startOfHour();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'blocked' => $blocked,
|
||||||
|
'meta' => [
|
||||||
|
'used' => min($current, $limit),
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => max(0, $limit - $current),
|
||||||
|
'window' => 'hourly',
|
||||||
|
'window_ends_at' => $windowEnds->toIso8601String(),
|
||||||
|
'window_ends_at_unix' => $windowEnds->timestamp,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function isNotModified(Request $request, string $etag): bool
|
private function isNotModified(Request $request, string $etag): bool
|
||||||
{
|
{
|
||||||
$ifNoneMatch = trim((string) $request->header('If-None-Match', ''));
|
$ifNoneMatch = trim((string) $request->header('If-None-Match', ''));
|
||||||
@@ -329,6 +536,53 @@ class EmojiApiController extends Controller
|
|||||||
return trim($value, '-');
|
return trim($value, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $items
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function filterItems(array $items, string $q, string $category, string $subSlug): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
|
||||||
|
$itemCategory = trim((string) ($item['category'] ?? ''));
|
||||||
|
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
|
||||||
|
|
||||||
|
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($q === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$haystack = strtolower(implode(' ', [
|
||||||
|
(string) ($item['emoji'] ?? ''),
|
||||||
|
(string) ($item['name'] ?? ''),
|
||||||
|
(string) ($item['slug'] ?? ''),
|
||||||
|
$itemCategory,
|
||||||
|
$itemSubcategory,
|
||||||
|
implode(' ', $item['keywords_en'] ?? []),
|
||||||
|
implode(' ', $item['keywords_id'] ?? []),
|
||||||
|
implode(' ', $item['aliases'] ?? []),
|
||||||
|
implode(' ', $item['shortcodes'] ?? []),
|
||||||
|
implode(' ', $item['alt_shortcodes'] ?? []),
|
||||||
|
implode(' ', $item['intent_tags'] ?? []),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ($token === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!str_contains($haystack, $token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $item
|
* @param array<string,mixed> $item
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
|
|||||||
207
app/app/Http/Controllers/Api/V1/ExtensionController.php
Normal file
207
app/app/Http/Controllers/Api/V1/ExtensionController.php
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Extension\ExtensionVerificationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ExtensionController extends Controller
|
||||||
|
{
|
||||||
|
/** @var array<string,mixed>|null */
|
||||||
|
private static ?array $dataset = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly ExtensionVerificationService $verifier)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = trim((string) $request->header('X-Extension-Token', ''));
|
||||||
|
$expected = config('dewemoji.public_access.extension_ids', []);
|
||||||
|
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $ok,
|
||||||
|
'verified' => $ok,
|
||||||
|
], $ok ? 200 : 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$token = trim((string) $request->header('X-Extension-Token', ''));
|
||||||
|
$expected = config('dewemoji.public_access.extension_ids', []);
|
||||||
|
$ok = $this->verifier->verifyToken($token, is_array($expected) ? $expected : []);
|
||||||
|
if (!$ok) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'extension_unverified'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $this->loadData();
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'data_load_failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $data['emojis'] ?? [];
|
||||||
|
$q = trim((string) ($request->query('q', $request->query('query', ''))));
|
||||||
|
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
|
||||||
|
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
|
||||||
|
$page = max((int) $request->query('page', 1), 1);
|
||||||
|
|
||||||
|
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1);
|
||||||
|
$maxLimit = max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
|
||||||
|
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
||||||
|
|
||||||
|
$filtered = $this->filterItems($items, $q, $category, $subSlug);
|
||||||
|
$total = count($filtered);
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
$pageItems = array_slice($filtered, $offset, $limit);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => array_map(fn (array $item): array => $this->transformItem($item), $pageItems),
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'plan' => 'free',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function loadData(): array
|
||||||
|
{
|
||||||
|
if (self::$dataset !== null) {
|
||||||
|
return self::$dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = (string) config('dewemoji.data_path');
|
||||||
|
if (!is_file($path)) {
|
||||||
|
throw new RuntimeException('Emoji dataset file was not found at: '.$path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
throw new RuntimeException('Emoji dataset file could not be read.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
throw new RuntimeException('Emoji dataset JSON is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$dataset = $decoded;
|
||||||
|
return self::$dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCategoryFilter(string $category): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($category));
|
||||||
|
if ($value === '' || $value === 'all') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [
|
||||||
|
'all' => 'all',
|
||||||
|
'smileys' => 'Smileys & Emotion',
|
||||||
|
'people' => 'People & Body',
|
||||||
|
'animals' => 'Animals & Nature',
|
||||||
|
'food' => 'Food & Drink',
|
||||||
|
'travel' => 'Travel & Places',
|
||||||
|
'activities' => 'Activities',
|
||||||
|
'objects' => 'Objects',
|
||||||
|
'symbols' => 'Symbols',
|
||||||
|
'flags' => 'Flags',
|
||||||
|
];
|
||||||
|
return $map[$value] ?? $category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $text): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($text));
|
||||||
|
$value = str_replace('&', 'and', $value);
|
||||||
|
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
|
||||||
|
return trim($value, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $items
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function filterItems(array $items, string $q, string $category, string $subSlug): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
|
||||||
|
$itemCategory = trim((string) ($item['category'] ?? ''));
|
||||||
|
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
|
||||||
|
|
||||||
|
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($q === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$haystack = strtolower(implode(' ', [
|
||||||
|
(string) ($item['emoji'] ?? ''),
|
||||||
|
(string) ($item['name'] ?? ''),
|
||||||
|
(string) ($item['slug'] ?? ''),
|
||||||
|
$itemCategory,
|
||||||
|
$itemSubcategory,
|
||||||
|
implode(' ', $item['keywords_en'] ?? []),
|
||||||
|
implode(' ', $item['keywords_id'] ?? []),
|
||||||
|
implode(' ', $item['aliases'] ?? []),
|
||||||
|
implode(' ', $item['shortcodes'] ?? []),
|
||||||
|
implode(' ', $item['alt_shortcodes'] ?? []),
|
||||||
|
implode(' ', $item['intent_tags'] ?? []),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$tokens = preg_split('/\s+/', strtolower($q)) ?: [];
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ($token === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!str_contains($haystack, $token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $item
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function transformItem(array $item): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'emoji' => (string) ($item['emoji'] ?? ''),
|
||||||
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
|
'slug' => (string) ($item['slug'] ?? ''),
|
||||||
|
'category' => (string) ($item['category'] ?? ''),
|
||||||
|
'subcategory' => (string) ($item['subcategory'] ?? ''),
|
||||||
|
'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false),
|
||||||
|
'summary' => $this->summary((string) ($item['description'] ?? ''), 150),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function summary(string $text, int $max): string
|
||||||
|
{
|
||||||
|
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
|
||||||
|
if (mb_strlen($text) <= $max) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(mb_substr($text, 0, $max - 1), " ,.;:-").'…';
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/app/Http/Controllers/Api/V1/PaypalWebhookController.php
Normal file
83
app/app/Http/Controllers/Api/V1/PaypalWebhookController.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use App\Services\Billing\PaypalWebhookProcessor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PaypalWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PaypalWebhookProcessor $processor)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->all();
|
||||||
|
$eventType = (string) ($payload['event_type'] ?? '');
|
||||||
|
$eventId = (string) ($payload['id'] ?? '');
|
||||||
|
|
||||||
|
$signatureOk = $this->verifySignature($request);
|
||||||
|
|
||||||
|
if ($eventId !== '' && WebhookEvent::where('provider', 'paypal')->where('event_id', $eventId)->exists()) {
|
||||||
|
return response()->json(['ok' => true, 'duplicate' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = WebhookEvent::create([
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'event_id' => $eventId !== '' ? $eventId : null,
|
||||||
|
'event_type' => $eventType !== '' ? $eventType : null,
|
||||||
|
'status' => $signatureOk ? 'received' : 'error',
|
||||||
|
'payload' => $payload,
|
||||||
|
'headers' => $this->extractHeaders($request),
|
||||||
|
'received_at' => now(),
|
||||||
|
'error' => $signatureOk ? null : 'signature_unverified',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$signatureOk) {
|
||||||
|
return response()->json(['ok' => true, 'signature' => 'unverified']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->processor->process($eventType, $payload);
|
||||||
|
$event->update([
|
||||||
|
'status' => 'processed',
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$event->update([
|
||||||
|
'status' => 'error',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySignature(Request $request): bool
|
||||||
|
{
|
||||||
|
// TODO: Implement PayPal signature verification using transmission headers + webhook ID.
|
||||||
|
// For now this returns true to allow local testing.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function extractHeaders(Request $request): array
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'paypal-transmission-id' => (string) $request->header('PayPal-Transmission-Id', ''),
|
||||||
|
'paypal-transmission-sig' => (string) $request->header('PayPal-Transmission-Sig', ''),
|
||||||
|
'paypal-cert-url' => (string) $request->header('PayPal-Cert-Url', ''),
|
||||||
|
'paypal-auth-algo' => (string) $request->header('PayPal-Auth-Algo', ''),
|
||||||
|
'paypal-transmission-time' => (string) $request->header('PayPal-Transmission-Time', ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_filter($headers, fn (string $value): bool => $value !== '');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/app/Http/Controllers/Api/V1/PricingController.php
Normal file
22
app/app/Http/Controllers/Api/V1/PricingController.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PricingController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$plans = PricingPlan::where('status', 'active')
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['code', 'name', 'currency', 'amount', 'period', 'meta']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'plans' => $plans,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/app/Http/Controllers/Api/V1/UserController.php
Normal file
171
app/app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use App\Services\Auth\ApiKeyService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiKeyService $keys
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'email' => 'required|email|max:255|unique:users,email',
|
||||||
|
'password' => 'required|min:8|max:255',
|
||||||
|
'name' => 'nullable|string|max:120',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$name = $data['name'] ?? strtok($data['email'], '@');
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $name ?: 'User',
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => Hash::make($data['password']),
|
||||||
|
'tier' => 'free',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$issued = null;
|
||||||
|
if ((string) $user->tier === 'personal') {
|
||||||
|
$issued = $this->keys->issueKey($user, 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'tier' => $user->tier,
|
||||||
|
],
|
||||||
|
'api_key' => $issued['plain'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'email' => 'required|email|max:255',
|
||||||
|
'password' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var User|null $user */
|
||||||
|
$user = User::where('email', $data['email'])->first();
|
||||||
|
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'invalid_credentials',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$issued = null;
|
||||||
|
if ((string) $user->tier === 'personal') {
|
||||||
|
$issued = $this->keys->issueKey($user, 'login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'tier' => $user->tier,
|
||||||
|
],
|
||||||
|
'api_key' => $issued['plain'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$key = $this->keys->parseKey($request);
|
||||||
|
if ($key === '') {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = $this->keys->hashKey($key);
|
||||||
|
UserApiKey::where('key_hash', $hash)->update([
|
||||||
|
'revoked_at' => Carbon::now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listApiKeys(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->keys->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = UserApiKey::where('user_id', $user->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get()
|
||||||
|
->map(fn (UserApiKey $key) => [
|
||||||
|
'id' => $key->id,
|
||||||
|
'prefix' => $key->key_prefix,
|
||||||
|
'name' => $key->name,
|
||||||
|
'created_at' => $key->created_at,
|
||||||
|
'last_used_at' => $key->last_used_at,
|
||||||
|
'revoked_at' => $key->revoked_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'items' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createApiKey(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->keys->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
if ((string) $user->tier !== 'personal') {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'nullable|string|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'api_key' => $issued['plain'],
|
||||||
|
'key' => [
|
||||||
|
'id' => $issued['record']->id,
|
||||||
|
'prefix' => $issued['record']->key_prefix,
|
||||||
|
'name' => $issued['record']->name,
|
||||||
|
'created_at' => $issued['record']->created_at,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeApiKey(Request $request, string $key): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->keys->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = $this->keys->hashKey($key);
|
||||||
|
$updated = UserApiKey::where('user_id', $user->id)
|
||||||
|
->where('key_hash', $hash)
|
||||||
|
->whereNull('revoked_at')
|
||||||
|
->update(['revoked_at' => Carbon::now()]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'revoked' => $updated > 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
app/app/Http/Controllers/Api/V1/UserKeywordController.php
Normal file
210
app/app/Http/Controllers/Api/V1/UserKeywordController.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\UserKeyword;
|
||||||
|
use App\Services\Auth\ApiKeyService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserKeywordController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiKeyService $keys
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureUser(Request $request): ?array
|
||||||
|
{
|
||||||
|
$user = $this->keys->resolveUser($request);
|
||||||
|
if (!$user) {
|
||||||
|
return ['error' => 'unauthorized', 'status' => 401];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['user' => $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $check['user'];
|
||||||
|
$items = UserKeyword::where('user_id', $user->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['id', 'emoji_slug', 'keyword', 'lang', 'created_at', 'updated_at']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'emoji_slug' => 'required|string|max:120',
|
||||||
|
'keyword' => 'required|string|max:200',
|
||||||
|
'lang' => 'nullable|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$lang = $data['lang'] ?? 'und';
|
||||||
|
$user = $check['user'];
|
||||||
|
$limit = $this->keywordLimitFor($user);
|
||||||
|
if ($limit !== null) {
|
||||||
|
$exists = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $data['emoji_slug'])
|
||||||
|
->where('keyword', $data['keyword'])
|
||||||
|
->exists();
|
||||||
|
if (!$exists) {
|
||||||
|
$count = UserKeyword::where('user_id', $user->id)->count();
|
||||||
|
if ($count >= $limit) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = UserKeyword::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji_slug' => $data['emoji_slug'],
|
||||||
|
'keyword' => $data['keyword'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lang' => $lang,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'item' => $item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = UserKeyword::where('user_id', $check['user']->id)
|
||||||
|
->where('id', $id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'deleted' => $deleted > 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'emoji_slug' => 'required|string|max:120',
|
||||||
|
'keyword' => 'required|string|max:200',
|
||||||
|
'lang' => 'nullable|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$item = UserKeyword::where('user_id', $check['user']->id)
|
||||||
|
->where('id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$item) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicate = UserKeyword::where('user_id', $check['user']->id)
|
||||||
|
->where('emoji_slug', $data['emoji_slug'])
|
||||||
|
->where('keyword', $data['keyword'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($duplicate && $duplicate->id !== $item->id) {
|
||||||
|
$item->delete();
|
||||||
|
$item = $duplicate;
|
||||||
|
} else {
|
||||||
|
$item->update([
|
||||||
|
'emoji_slug' => $data['emoji_slug'],
|
||||||
|
'keyword' => $data['keyword'],
|
||||||
|
'lang' => $data['lang'] ?? 'und',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'item' => $item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = UserKeyword::where('user_id', $check['user']->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['emoji_slug', 'keyword', 'lang']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'items' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$check = $this->ensureUser($request);
|
||||||
|
if (!isset($check['user'])) {
|
||||||
|
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'items' => 'required|array',
|
||||||
|
'items.*.emoji_slug' => 'required|string|max:120',
|
||||||
|
'items.*.keyword' => 'required|string|max:200',
|
||||||
|
'items.*.lang' => 'nullable|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$user = $check['user'];
|
||||||
|
$limit = $this->keywordLimitFor($user);
|
||||||
|
$current = UserKeyword::where('user_id', $user->id)->count();
|
||||||
|
foreach ($data['items'] as $row) {
|
||||||
|
$exists = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $row['emoji_slug'])
|
||||||
|
->where('keyword', $row['keyword'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (!$exists && $limit !== null && $current >= $limit) {
|
||||||
|
$skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UserKeyword::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji_slug' => $row['emoji_slug'],
|
||||||
|
'keyword' => $row['keyword'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lang' => $row['lang'] ?? 'und',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$exists) {
|
||||||
|
$current += 1;
|
||||||
|
}
|
||||||
|
$count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'imported' => $count, 'skipped' => $skipped]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keywordLimitFor($user): ?int
|
||||||
|
{
|
||||||
|
if ((string) ($user->tier ?? 'free') === 'personal') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) config('dewemoji.pagination.free_max_limit', 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class AuthenticatedSessionController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the login view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming authentication request.
|
||||||
|
*/
|
||||||
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->authenticate();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard.overview', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy an authenticated session.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ConfirmablePasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the confirm password view.
|
||||||
|
*/
|
||||||
|
public function show(): View
|
||||||
|
{
|
||||||
|
return view('auth.confirm-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! Auth::guard('web')->validate([
|
||||||
|
'email' => $request->user()->email,
|
||||||
|
'password' => $request->password,
|
||||||
|
])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => __('auth.password'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('auth.password_confirmed_at', time());
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard.overview', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmailVerificationNotificationController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a new email verification notification.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard.overview', absolute: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return back()->with('status', 'verification-link-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class EmailVerificationPromptController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the email verification prompt.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): RedirectResponse|View
|
||||||
|
{
|
||||||
|
return $request->user()->hasVerifiedEmail()
|
||||||
|
? redirect()->intended(route('dashboard.overview', absolute: false))
|
||||||
|
: view('auth.verify-email');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/app/Http/Controllers/Auth/NewPasswordController.php
Normal file
62
app/app/Http/Controllers/Auth/NewPasswordController.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class NewPasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset view.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
return view('auth.reset-password', ['request' => $request]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming new password request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => ['required'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Here we will attempt to reset the user's password. If it is successful we
|
||||||
|
// will update the password on an actual user model and persist it to the
|
||||||
|
// database. Otherwise we will parse the error and return the response.
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user) use ($request) {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
'remember_token' => Str::random(60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the password was successfully reset, we will redirect the user back to
|
||||||
|
// the application's home authenticated view. If there is an error we can
|
||||||
|
// redirect them back to where they came from with their error message.
|
||||||
|
return $status == Password::PASSWORD_RESET
|
||||||
|
? redirect()->route('login')->with('status', __($status))
|
||||||
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/app/Http/Controllers/Auth/PasswordController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validateWithBag('updatePassword', [
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'password-updated');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class PasswordResetLinkController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the password reset link request view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming password reset link request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
|
// to send the link, we will examine the response then see the message we
|
||||||
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $status == Password::RESET_LINK_SENT
|
||||||
|
? back()->with('status', __($status))
|
||||||
|
: back()->withInput($request->only('email'))
|
||||||
|
->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
54
app/app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Auth\Events\Registered;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class RegisteredUserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the registration view.
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.register');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming registration request.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $request->name,
|
||||||
|
'email' => $request->email,
|
||||||
|
'password' => Hash::make($request->password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
event(new Registered($user));
|
||||||
|
if ($user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail()) {
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
Auth::login($user);
|
||||||
|
|
||||||
|
return redirect(route('dashboard.overview', absolute: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class VerifyEmailController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mark the authenticated user's email address as verified.
|
||||||
|
*/
|
||||||
|
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
|
event(new Verified($request->user()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard.overview', absolute: false).'?verified=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
240
app/app/Http/Controllers/Billing/PakasirController.php
Normal file
240
app/app/Http/Controllers/Billing/PakasirController.php
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Billing;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PakasirController extends Controller
|
||||||
|
{
|
||||||
|
public function createTransaction(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'plan_code' => 'required|string|in:personal_monthly,personal_annual,personal_lifetime',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['error' => 'auth_required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = config('dewemoji.billing.providers.pakasir', []);
|
||||||
|
$enabled = (bool) ($config['enabled'] ?? false);
|
||||||
|
$apiBase = rtrim((string) ($config['api_base'] ?? ''), '/');
|
||||||
|
$apiKey = (string) ($config['api_key'] ?? '');
|
||||||
|
$project = (string) ($config['project'] ?? '');
|
||||||
|
$timeout = (int) ($config['timeout'] ?? 10);
|
||||||
|
|
||||||
|
if (!$enabled || $apiBase === '' || $apiKey === '' || $project === '') {
|
||||||
|
return response()->json(['error' => 'pakasir_not_configured'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountIdr = $this->resolvePlanAmountIdr($data['plan_code']);
|
||||||
|
if ($amountIdr <= 0) {
|
||||||
|
return response()->json(['error' => 'invalid_plan_amount'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('provider', 'pakasir')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
Order::where('user_id', $user->id)
|
||||||
|
->where('provider', 'pakasir')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
Payment::where('user_id', $user->id)
|
||||||
|
->where('provider', 'pakasir')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
|
||||||
|
$order = Order::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_code' => $data['plan_code'],
|
||||||
|
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => $amountIdr,
|
||||||
|
'status' => 'pending',
|
||||||
|
'provider' => 'pakasir',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderRef = 'DW-'.$order->id.'-'.now()->format('ymdHis');
|
||||||
|
$payload = [
|
||||||
|
'project' => $project,
|
||||||
|
'order_id' => $orderRef,
|
||||||
|
'amount' => $amountIdr,
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'description' => 'Dewemoji '.$data['plan_code'].' for '.$user->email,
|
||||||
|
];
|
||||||
|
|
||||||
|
$endpoint = $apiBase.'/api/transactioncreate/qris';
|
||||||
|
$res = Http::timeout($timeout)->post($endpoint, $payload);
|
||||||
|
|
||||||
|
if (!$res->ok()) {
|
||||||
|
Log::warning('Pakasir create transaction failed', ['body' => $res->body()]);
|
||||||
|
return response()->json(['error' => 'pakasir_create_failed'], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $res->json();
|
||||||
|
$payment = is_array($body['payment'] ?? null) ? $body['payment'] : $body;
|
||||||
|
$paymentNumber = (string) ($payment['payment_number'] ?? '');
|
||||||
|
$status = (string) ($payment['status'] ?? 'pending');
|
||||||
|
$expiredAt = (string) ($payment['expired_at'] ?? '');
|
||||||
|
$totalPayment = (int) ($payment['total_payment'] ?? $amountIdr);
|
||||||
|
|
||||||
|
$order->update([
|
||||||
|
'provider_ref' => $orderRef,
|
||||||
|
'status' => $status === 'success' ? 'pending' : 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Payment::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'provider' => 'pakasir',
|
||||||
|
'type' => $data['plan_code'] === 'personal_lifetime' ? 'one_time' : 'subscription',
|
||||||
|
'plan_code' => $data['plan_code'],
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => $amountIdr,
|
||||||
|
'status' => 'pending',
|
||||||
|
'provider_ref' => $paymentNumber !== '' ? $paymentNumber : $orderRef,
|
||||||
|
'raw_payload' => $body,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'order_id' => $orderRef,
|
||||||
|
'payment_number' => $paymentNumber,
|
||||||
|
'amount' => $amountIdr,
|
||||||
|
'total_payment' => $totalPayment,
|
||||||
|
'expired_at' => $expiredAt,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelPending(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['error' => 'auth_required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderRef = (string) $request->input('order_id', '');
|
||||||
|
|
||||||
|
$orderQuery = Order::where('user_id', $user->id)
|
||||||
|
->where('provider', 'pakasir')
|
||||||
|
->where('status', 'pending');
|
||||||
|
|
||||||
|
if ($orderRef !== '') {
|
||||||
|
$orderQuery->where('provider_ref', $orderRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $orderQuery->orderByDesc('id')->first();
|
||||||
|
if (!$order) {
|
||||||
|
return response()->json(['ok' => true, 'cancelled' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update(['status' => 'cancelled']);
|
||||||
|
Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'cancelled']);
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('provider', 'pakasir')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'cancelled' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhook(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->all();
|
||||||
|
$eventId = (string) ($payload['id'] ?? $payload['event_id'] ?? Str::uuid());
|
||||||
|
$eventType = (string) ($payload['event_type'] ?? $payload['status'] ?? 'event');
|
||||||
|
|
||||||
|
WebhookEvent::create([
|
||||||
|
'provider' => 'pakasir',
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'status' => 'received',
|
||||||
|
'payload' => $payload,
|
||||||
|
'headers' => $request->headers->all(),
|
||||||
|
'received_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->handlePakasirPayload($payload);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handlePakasirPayload(array $payload): void
|
||||||
|
{
|
||||||
|
$status = strtolower((string) ($payload['status'] ?? ''));
|
||||||
|
if (!in_array($status, ['paid', 'success', 'settlement', 'completed'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderId = (string) ($payload['order_id'] ?? '');
|
||||||
|
if ($orderId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = Order::where('provider', 'pakasir')->where('provider_ref', $orderId)->first();
|
||||||
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update(['status' => 'paid']);
|
||||||
|
Payment::where('order_id', $order->id)->update(['status' => 'paid']);
|
||||||
|
|
||||||
|
$user = User::find($order->user_id);
|
||||||
|
if (!$user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = null;
|
||||||
|
if ($order->plan_code === 'personal_monthly') {
|
||||||
|
$expiresAt = now()->addMonth();
|
||||||
|
} elseif ($order->plan_code === 'personal_annual') {
|
||||||
|
$expiresAt = now()->addYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription::updateOrCreate(
|
||||||
|
[
|
||||||
|
'provider' => 'pakasir',
|
||||||
|
'provider_ref' => $orderId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan' => $order->plan_code,
|
||||||
|
'status' => 'active',
|
||||||
|
'started_at' => now(),
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
User::where('id', $user->id)->update(['tier' => 'personal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePlanAmountIdr(string $planCode): int
|
||||||
|
{
|
||||||
|
$plan = PricingPlan::where('code', $planCode)->where('status', 'active')->first();
|
||||||
|
if ($plan) {
|
||||||
|
return (int) $plan->amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = collect(config('dewemoji.pricing.defaults', []))->keyBy('code');
|
||||||
|
$fallback = $defaults->get($planCode);
|
||||||
|
if (!$fallback) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($fallback['amount'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
336
app/app/Http/Controllers/Billing/PayPalController.php
Normal file
336
app/app/Http/Controllers/Billing/PayPalController.php
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Billing;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use App\Services\System\SettingsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PayPalController extends Controller
|
||||||
|
{
|
||||||
|
public function createSubscription(Request $request): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'plan_code' => 'required|string|in:personal_monthly,personal_annual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['error' => 'auth_required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$mode = $this->billingMode();
|
||||||
|
if (!$this->paypalConfigured($mode)) {
|
||||||
|
return response()->json(['error' => 'paypal_not_configured'], 422);
|
||||||
|
}
|
||||||
|
$planId = $this->resolvePlanId($data['plan_code'], $mode);
|
||||||
|
if (!$planId) {
|
||||||
|
return response()->json(['error' => 'paypal_plan_missing'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->getAccessToken($mode);
|
||||||
|
if (!$token) {
|
||||||
|
return response()->json(['error' => 'paypal_auth_failed'], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appUrl = rtrim(config('app.url'), '/');
|
||||||
|
$payload = [
|
||||||
|
'plan_id' => $planId,
|
||||||
|
'subscriber' => [
|
||||||
|
'name' => [
|
||||||
|
'given_name' => $user->name ?? 'User',
|
||||||
|
'surname' => 'Dewemoji',
|
||||||
|
],
|
||||||
|
'email_address' => $user->email,
|
||||||
|
],
|
||||||
|
'application_context' => [
|
||||||
|
'brand_name' => 'Dewemoji',
|
||||||
|
'locale' => 'en-US',
|
||||||
|
'user_action' => 'SUBSCRIBE_NOW',
|
||||||
|
'return_url' => $appUrl.'/billing/paypal/return?status=success',
|
||||||
|
'cancel_url' => $appUrl.'/billing/paypal/return?status=cancel',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/billing/subscriptions', $payload);
|
||||||
|
|
||||||
|
$body = $res->json();
|
||||||
|
$subscriptionId = $body['id'] ?? null;
|
||||||
|
$approveUrl = collect($body['links'] ?? [])->firstWhere('rel', 'approve')['href'] ?? null;
|
||||||
|
|
||||||
|
if (!$subscriptionId || !$approveUrl) {
|
||||||
|
if (!$res->ok()) {
|
||||||
|
Log::warning('PayPal create subscription failed', [
|
||||||
|
'status' => $res->status(),
|
||||||
|
'body' => $res->body(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Log::warning('PayPal create subscription missing approve link', [
|
||||||
|
'status' => $res->status(),
|
||||||
|
'body' => $res->body(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return response()->json(['error' => 'paypal_invalid_response'], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountUsd = $this->resolvePlanAmountUsd($data['plan_code']);
|
||||||
|
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('provider', 'paypal')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
Order::where('user_id', $user->id)
|
||||||
|
->where('provider', 'paypal')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
Payment::where('user_id', $user->id)
|
||||||
|
->where('provider', 'paypal')
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update(['status' => 'cancelled']);
|
||||||
|
|
||||||
|
$order = Order::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_code' => $data['plan_code'],
|
||||||
|
'type' => 'subscription',
|
||||||
|
'currency' => 'USD',
|
||||||
|
'amount' => $amountUsd,
|
||||||
|
'status' => 'pending',
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'provider_ref' => $subscriptionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Payment::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'type' => 'subscription',
|
||||||
|
'plan_code' => $data['plan_code'],
|
||||||
|
'currency' => 'USD',
|
||||||
|
'amount' => $amountUsd,
|
||||||
|
'status' => 'pending',
|
||||||
|
'provider_ref' => $subscriptionId,
|
||||||
|
'raw_payload' => $body,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Subscription::firstOrCreate([
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'provider_ref' => $subscriptionId,
|
||||||
|
], [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan' => $data['plan_code'],
|
||||||
|
'status' => 'pending',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['approve_url' => $approveUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function return(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$status = (string) $request->query('status', 'success');
|
||||||
|
return redirect()->route('dashboard.billing', ['status' => $status]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhook(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->all();
|
||||||
|
$eventId = (string) ($payload['id'] ?? '');
|
||||||
|
$eventType = (string) ($payload['event_type'] ?? '');
|
||||||
|
|
||||||
|
$mode = config('dewemoji.billing.mode', 'sandbox');
|
||||||
|
$webhookId = config("dewemoji.billing.providers.paypal.webhook_ids.{$mode}");
|
||||||
|
|
||||||
|
if ($webhookId) {
|
||||||
|
$verified = $this->verifySignature($mode, $webhookId, $payload, $request);
|
||||||
|
if (!$verified) {
|
||||||
|
return response()->json(['error' => 'invalid_signature'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$event = WebhookEvent::create([
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'event_id' => $eventId ?: (string) Str::uuid(),
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'status' => 'received',
|
||||||
|
'payload' => $payload,
|
||||||
|
'headers' => $request->headers->all(),
|
||||||
|
'received_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$processed = $this->processPayPalEvent($payload);
|
||||||
|
$event->update([
|
||||||
|
'status' => $processed ? 'processed' : 'received',
|
||||||
|
'processed_at' => $processed ? now() : null,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$event->update([
|
||||||
|
'status' => 'error',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processPayPalEvent(array $payload): bool
|
||||||
|
{
|
||||||
|
$type = (string) ($payload['event_type'] ?? '');
|
||||||
|
$resource = $payload['resource'] ?? [];
|
||||||
|
$subscriptionId = (string) ($resource['id'] ?? $resource['subscription_id'] ?? '');
|
||||||
|
|
||||||
|
if ($subscriptionId === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'BILLING.SUBSCRIPTION.ACTIVATED') {
|
||||||
|
$sub = Subscription::firstOrNew([
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'provider_ref' => $subscriptionId,
|
||||||
|
]);
|
||||||
|
if (!$sub->user_id) {
|
||||||
|
$order = Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
|
||||||
|
if ($order) {
|
||||||
|
$sub->user_id = $order->user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sub->status = 'active';
|
||||||
|
$sub->started_at = $sub->started_at ?? now();
|
||||||
|
$sub->next_renewal_at = $resource['billing_info']['next_billing_time'] ?? null;
|
||||||
|
$sub->save();
|
||||||
|
|
||||||
|
if ($sub->user_id) {
|
||||||
|
User::where('id', $sub->user_id)->update(['tier' => 'personal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
|
||||||
|
Payment::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($type, ['BILLING.SUBSCRIPTION.CANCELLED', 'BILLING.SUBSCRIPTION.SUSPENDED'], true)) {
|
||||||
|
$sub = Subscription::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
|
||||||
|
if ($sub) {
|
||||||
|
$sub->status = 'canceled';
|
||||||
|
$sub->canceled_at = now();
|
||||||
|
$sub->save();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePlanId(string $planCode, string $mode): ?string
|
||||||
|
{
|
||||||
|
$plan = PricingPlan::where('code', $planCode)->first();
|
||||||
|
if ($plan) {
|
||||||
|
$meta = $plan->meta ?? [];
|
||||||
|
$stored = $meta['paypal'][$mode]['plan']['id'] ?? null;
|
||||||
|
if ($stored) {
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$planCode}") ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePlanAmountUsd(string $planCode): int
|
||||||
|
{
|
||||||
|
$plan = PricingPlan::where('code', $planCode)->first();
|
||||||
|
if (!$plan) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
|
||||||
|
if ($rate <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (int) round($plan->amount / $rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAccessToken(string $mode): ?string
|
||||||
|
{
|
||||||
|
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
|
||||||
|
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
|
||||||
|
if (!$clientId || !$clientSecret || !$apiBase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = Http::asForm()
|
||||||
|
->withBasicAuth($clientId, $clientSecret)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
|
||||||
|
'grant_type' => 'client_credentials',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$res->ok()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->json('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paypalConfigured(string $mode): bool
|
||||||
|
{
|
||||||
|
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
|
||||||
|
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
|
||||||
|
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
|
||||||
|
return $enabled && $clientId && $clientSecret && $apiBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function billingMode(): string
|
||||||
|
{
|
||||||
|
$settings = app(SettingsService::class);
|
||||||
|
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySignature(string $mode, string $webhookId, array $payload, Request $request): bool
|
||||||
|
{
|
||||||
|
$token = $this->getAccessToken($mode);
|
||||||
|
if (!$token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$verifyPayload = [
|
||||||
|
'auth_algo' => $request->header('paypal-auth-algo'),
|
||||||
|
'cert_url' => $request->header('paypal-cert-url'),
|
||||||
|
'transmission_id' => $request->header('paypal-transmission-id'),
|
||||||
|
'transmission_sig' => $request->header('paypal-transmission-sig'),
|
||||||
|
'transmission_time' => $request->header('paypal-transmission-time'),
|
||||||
|
'webhook_id' => $webhookId,
|
||||||
|
'webhook_event' => $payload,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (collect($verifyPayload)->contains(fn ($v) => empty($v))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/notifications/verify-webhook-signature', $verifyPayload);
|
||||||
|
|
||||||
|
return $res->ok() && $res->json('verification_status') === 'SUCCESS';
|
||||||
|
}
|
||||||
|
}
|
||||||
688
app/app/Http/Controllers/Dashboard/AdminDashboardController.php
Normal file
688
app/app/Http/Controllers/Dashboard/AdminDashboardController.php
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PricingChange;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use App\Models\UserKeyword;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use App\Models\AdminAuditLog;
|
||||||
|
use App\Services\Billing\PayPalPlanSyncService;
|
||||||
|
use App\Services\System\SettingsService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class AdminDashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SettingsService $settings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users(Request $request): View
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$tier = trim((string) $request->query('tier', ''));
|
||||||
|
$role = trim((string) $request->query('role', ''));
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
|
||||||
|
$query = User::query();
|
||||||
|
if ($q !== '') {
|
||||||
|
$query->where(function ($sub) use ($q): void {
|
||||||
|
$sub->where('email', 'like', '%'.$q.'%')
|
||||||
|
->orWhere('name', 'like', '%'.$q.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($tier !== '') {
|
||||||
|
$query->where('tier', $tier);
|
||||||
|
}
|
||||||
|
if ($role !== '') {
|
||||||
|
$query->where('role', $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return view('dashboard.admin.users', [
|
||||||
|
'users' => $users,
|
||||||
|
'filters' => ['q' => $q, 'tier' => $tier, 'role' => $role],
|
||||||
|
'sort' => $sort,
|
||||||
|
'dir' => $dir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userDetail(User $user): View
|
||||||
|
{
|
||||||
|
$subscriptions = Subscription::where('user_id', $user->id)->orderByDesc('id')->get();
|
||||||
|
|
||||||
|
return view('dashboard.admin.user-show', [
|
||||||
|
'user' => $user,
|
||||||
|
'subscriptions' => $subscriptions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUserTier(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => 'required|integer',
|
||||||
|
'tier' => 'required|string|in:free,personal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
User::where('id', $data['user_id'])->update(['tier' => $data['tier']]);
|
||||||
|
if ($data['tier'] === 'free') {
|
||||||
|
UserApiKey::where('user_id', $data['user_id'])->update(['revoked_at' => now()]);
|
||||||
|
}
|
||||||
|
$this->logAdminAction('user_tier_update', $data);
|
||||||
|
|
||||||
|
return back()->with('status', 'User tier updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUser(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'nullable|string|max:120',
|
||||||
|
'email' => 'required|email|max:255|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8|max:255',
|
||||||
|
'role' => 'required|string|in:admin,user',
|
||||||
|
'tier' => 'required|string|in:free,personal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$name = $data['name'] ?: strtok($data['email'], '@');
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $name ?: 'User',
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => Hash::make($data['password']),
|
||||||
|
'role' => $data['role'],
|
||||||
|
'tier' => $data['tier'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logAdminAction('user_created', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'role' => $user->role,
|
||||||
|
'tier' => $user->tier,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'User created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteUser(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
$admin = $request->user();
|
||||||
|
|
||||||
|
if ($admin && $admin->id === $user->id) {
|
||||||
|
return back()->withErrors(['user' => 'You cannot delete your own account.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($user->role ?? 'user') === 'admin' && User::where('role', 'admin')->count() <= 1) {
|
||||||
|
return back()->withErrors(['user' => 'Cannot delete the last admin account.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user): void {
|
||||||
|
Payment::where('user_id', $user->id)->delete();
|
||||||
|
Order::where('user_id', $user->id)->delete();
|
||||||
|
Subscription::where('user_id', $user->id)->delete();
|
||||||
|
UserApiKey::where('user_id', $user->id)->delete();
|
||||||
|
UserKeyword::where('user_id', $user->id)->delete();
|
||||||
|
DB::table('password_reset_tokens')->where('email', $user->email)->delete();
|
||||||
|
$user->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->logAdminAction('user_deleted', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'User deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscriptions(Request $request): View
|
||||||
|
{
|
||||||
|
$query = Subscription::query()->with('user:id,email,name,tier');
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
|
||||||
|
if ($userId = $request->query('user_id')) {
|
||||||
|
$query->where('user_id', (int) $userId);
|
||||||
|
}
|
||||||
|
if ($email = $request->query('email')) {
|
||||||
|
$query->whereHas('user', fn ($q) => $q->where('email', $email));
|
||||||
|
}
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscriptions = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return view('dashboard.admin.subscriptions', [
|
||||||
|
'subscriptions' => $subscriptions,
|
||||||
|
'filters' => [
|
||||||
|
'user_id' => $request->query('user_id'),
|
||||||
|
'email' => $request->query('email'),
|
||||||
|
'status' => $request->query('status'),
|
||||||
|
],
|
||||||
|
'sort' => $sort,
|
||||||
|
'dir' => $dir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subscriptionDetail(Subscription $subscription): View
|
||||||
|
{
|
||||||
|
$subscription->loadMissing('user:id,name,email,tier,role');
|
||||||
|
|
||||||
|
return view('dashboard.admin.subscription-show', [
|
||||||
|
'subscription' => $subscription,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantSubscription(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => 'nullable|integer',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
'plan' => 'required|string|max:20',
|
||||||
|
'status' => 'required|string|in:active,pending,revoked',
|
||||||
|
'provider' => 'nullable|string|max:20',
|
||||||
|
'provider_ref' => 'nullable|string|max:100',
|
||||||
|
'started_at' => 'nullable|date',
|
||||||
|
'expires_at' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
|
||||||
|
if (!$user) {
|
||||||
|
return back()->withErrors(['user' => 'User not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$startedAt = $data['started_at'] ? Carbon::parse($data['started_at']) : now();
|
||||||
|
$expiresAt = $data['expires_at'] ? Carbon::parse($data['expires_at']) : null;
|
||||||
|
|
||||||
|
Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan' => $data['plan'],
|
||||||
|
'status' => $data['status'],
|
||||||
|
'provider' => $data['provider'] ?? 'admin',
|
||||||
|
'provider_ref' => $data['provider_ref'] ?? null,
|
||||||
|
'started_at' => $startedAt,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data['status'] === 'active') {
|
||||||
|
$user->update(['tier' => 'personal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logAdminAction('subscription_grant', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'plan' => $data['plan'],
|
||||||
|
'status' => $data['status'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Subscription granted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeSubscription(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'subscription_id' => 'nullable|integer',
|
||||||
|
'user_id' => 'nullable|integer',
|
||||||
|
'email' => 'nullable|email|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($data['subscription_id'])) {
|
||||||
|
$sub = Subscription::find($data['subscription_id']);
|
||||||
|
if (!$sub) {
|
||||||
|
return back()->withErrors(['subscription' => 'Subscription not found.']);
|
||||||
|
}
|
||||||
|
$sub->update(['status' => 'revoked', 'expires_at' => now()]);
|
||||||
|
$this->syncUserTier($sub->user_id);
|
||||||
|
$this->logAdminAction('subscription_revoke', [
|
||||||
|
'subscription_id' => $sub->id,
|
||||||
|
'user_id' => $sub->user_id,
|
||||||
|
]);
|
||||||
|
return back()->with('status', 'Subscription revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->resolveUser($data['user_id'] ?? null, $data['email'] ?? null);
|
||||||
|
if (!$user) {
|
||||||
|
return back()->withErrors(['user' => 'User not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->update(['status' => 'revoked', 'expires_at' => now()]);
|
||||||
|
$this->syncUserTier($user->id);
|
||||||
|
$this->logAdminAction('subscription_revoke_all', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'User subscriptions revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pricing(): View
|
||||||
|
{
|
||||||
|
$plans = PricingPlan::orderBy('id')->get();
|
||||||
|
$changes = PricingChange::orderByDesc('id')->limit(5)->get();
|
||||||
|
|
||||||
|
return view('dashboard.admin.pricing', [
|
||||||
|
'plans' => $plans,
|
||||||
|
'changes' => $changes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePricing(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'plans' => 'required|array|min:1',
|
||||||
|
'plans.*.code' => 'required|string|max:30',
|
||||||
|
'plans.*.name' => 'required|string|max:50',
|
||||||
|
'plans.*.amount_idr' => 'required|integer|min:0',
|
||||||
|
'plans.*.amount_usd' => 'nullable|numeric|min:0',
|
||||||
|
'plans.*.period' => 'nullable|string|max:20',
|
||||||
|
'plans.*.status' => 'nullable|string|max:20',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$before = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($data): void {
|
||||||
|
foreach ($data['plans'] as $plan) {
|
||||||
|
$meta = [
|
||||||
|
'prices' => [
|
||||||
|
'IDR' => (int) $plan['amount_idr'],
|
||||||
|
'USD' => isset($plan['amount_usd']) ? (float) $plan['amount_usd'] : null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
PricingPlan::updateOrCreate(
|
||||||
|
['code' => $plan['code']],
|
||||||
|
[
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => (int) $plan['amount_idr'],
|
||||||
|
'period' => $plan['period'] ?? null,
|
||||||
|
'status' => $plan['status'] ?? 'active',
|
||||||
|
'meta' => $meta,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
PricingChange::create([
|
||||||
|
'admin_ref' => (string) auth()->user()?->email,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
]);
|
||||||
|
$this->logAdminAction('pricing_update', ['plans' => count($data['plans'])]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Pricing updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncPaypalPlans(PayPalPlanSyncService $sync): RedirectResponse
|
||||||
|
{
|
||||||
|
$resultSandbox = $sync->sync('sandbox');
|
||||||
|
$resultLive = $sync->sync('live');
|
||||||
|
|
||||||
|
$this->logAdminAction('paypal_plan_sync', [
|
||||||
|
'sandbox' => $resultSandbox,
|
||||||
|
'live' => $resultLive,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('status', 'PayPal plans synced (sandbox + live).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPricingSnapshot(): RedirectResponse
|
||||||
|
{
|
||||||
|
$before = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
|
||||||
|
PricingChange::create([
|
||||||
|
'admin_ref' => (string) auth()->user()?->email,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $before,
|
||||||
|
]);
|
||||||
|
$this->logAdminAction('pricing_snapshot', ['plans' => count($before)]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Pricing snapshot created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPricing(): RedirectResponse
|
||||||
|
{
|
||||||
|
$defaults = config('dewemoji.pricing.defaults', []);
|
||||||
|
$before = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($defaults): void {
|
||||||
|
PricingPlan::query()->delete();
|
||||||
|
foreach ($defaults as $plan) {
|
||||||
|
PricingPlan::create([
|
||||||
|
'code' => $plan['code'],
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'currency' => $plan['currency'],
|
||||||
|
'amount' => $plan['amount'],
|
||||||
|
'period' => $plan['period'],
|
||||||
|
'status' => $plan['status'],
|
||||||
|
'meta' => $plan['meta'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$after = PricingPlan::orderBy('id')->get()->toArray();
|
||||||
|
PricingChange::create([
|
||||||
|
'admin_ref' => (string) auth()->user()?->email,
|
||||||
|
'before' => $before,
|
||||||
|
'after' => $after,
|
||||||
|
]);
|
||||||
|
$this->logAdminAction('pricing_reset', ['plans' => count($after)]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Pricing reset to defaults.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhooks(Request $request): View
|
||||||
|
{
|
||||||
|
$query = WebhookEvent::query();
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
if ($provider = $request->query('provider')) {
|
||||||
|
$query->where('provider', (string) $provider);
|
||||||
|
}
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$events = $query->orderBy($sort, $dir)->paginate(20)->withQueryString();
|
||||||
|
$providers = WebhookEvent::query()->distinct('provider')->orderBy('provider')->pluck('provider');
|
||||||
|
|
||||||
|
return view('dashboard.admin.webhooks', [
|
||||||
|
'events' => $events,
|
||||||
|
'filters' => [
|
||||||
|
'provider' => $request->query('provider'),
|
||||||
|
'status' => $request->query('status'),
|
||||||
|
],
|
||||||
|
'providers' => $providers,
|
||||||
|
'sort' => $sort,
|
||||||
|
'dir' => $dir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhookDetail(WebhookEvent $event): View
|
||||||
|
{
|
||||||
|
return view('dashboard.admin.webhook-show', [
|
||||||
|
'event' => $event,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replayWebhook(int $id): RedirectResponse
|
||||||
|
{
|
||||||
|
$event = WebhookEvent::find($id);
|
||||||
|
if (!$event) {
|
||||||
|
return back()->withErrors(['webhook' => 'Webhook not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->update(['status' => 'pending', 'processed_at' => null, 'error' => null]);
|
||||||
|
$this->logAdminAction('webhook_replay', ['event_id' => $event->id]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Webhook queued for replay.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replayFailedWebhooks(): RedirectResponse
|
||||||
|
{
|
||||||
|
$count = WebhookEvent::where('status', 'error')->count();
|
||||||
|
WebhookEvent::where('status', 'error')->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'processed_at' => null,
|
||||||
|
'error' => null,
|
||||||
|
]);
|
||||||
|
$this->logAdminAction('webhook_replay_failed', ['count' => $count]);
|
||||||
|
|
||||||
|
return back()->with('status', 'Failed webhooks queued for replay.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(): View
|
||||||
|
{
|
||||||
|
$all = $this->settings->all();
|
||||||
|
|
||||||
|
return view('dashboard.admin.settings', [
|
||||||
|
'settings' => $all,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditLogs(Request $request): View
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$action = trim((string) $request->query('action', ''));
|
||||||
|
|
||||||
|
$query = AdminAuditLog::query()->orderByDesc('id');
|
||||||
|
if ($q !== '') {
|
||||||
|
$query->where(function ($sub) use ($q): void {
|
||||||
|
$sub->where('admin_email', 'like', '%'.$q.'%')
|
||||||
|
->orWhere('action', 'like', '%'.$q.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($action !== '') {
|
||||||
|
$query->where('action', $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = AdminAuditLog::query()->distinct('action')->orderBy('action')->pluck('action');
|
||||||
|
$logs = $query->paginate(25)->withQueryString();
|
||||||
|
|
||||||
|
return view('dashboard.admin.audit-logs', [
|
||||||
|
'logs' => $logs,
|
||||||
|
'filters' => ['q' => $q, 'action' => $action],
|
||||||
|
'actions' => $actions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateSettings(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'maintenance_enabled' => 'nullable|boolean',
|
||||||
|
'public_enforce' => 'nullable|boolean',
|
||||||
|
'public_origins' => 'nullable|string',
|
||||||
|
'public_extension_ids' => 'nullable|string',
|
||||||
|
'public_hourly_limit' => 'nullable|integer|min:0',
|
||||||
|
'billing_mode' => 'nullable|string|in:sandbox,live',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'maintenance_enabled' => (bool) ($data['maintenance_enabled'] ?? false),
|
||||||
|
'public_enforce' => (bool) ($data['public_enforce'] ?? false),
|
||||||
|
'public_origins' => $this->splitCsv($data['public_origins'] ?? ''),
|
||||||
|
'public_extension_ids' => $this->splitCsv($data['public_extension_ids'] ?? ''),
|
||||||
|
'public_hourly_limit' => (int) ($data['public_hourly_limit'] ?? 0),
|
||||||
|
'billing_mode' => $data['billing_mode'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->settings->setMany($payload, (string) auth()->user()?->email);
|
||||||
|
$this->logAdminAction('settings_update', $payload);
|
||||||
|
|
||||||
|
return back()->with('status', 'Settings updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveUser(?int $userId, ?string $email): ?User
|
||||||
|
{
|
||||||
|
if ($userId) {
|
||||||
|
return User::find($userId);
|
||||||
|
}
|
||||||
|
if ($email) {
|
||||||
|
return User::where('email', $email)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncUserTier(int $userId): void
|
||||||
|
{
|
||||||
|
$active = Subscription::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where(function ($q): void {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
User::where('id', $userId)->update([
|
||||||
|
'tier' => $active ? 'personal' : 'free',
|
||||||
|
]);
|
||||||
|
if (!$active) {
|
||||||
|
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportCsv(Request $request, string $type): StreamedResponse
|
||||||
|
{
|
||||||
|
$type = strtolower($type);
|
||||||
|
$filename = "dewemoji-{$type}-export-".now()->format('Ymd_His').".csv";
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($type, $request): void {
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
if ($type === 'users') {
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$tier = trim((string) $request->query('tier', ''));
|
||||||
|
$role = trim((string) $request->query('role', ''));
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'name', 'email', 'role', 'tier', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
|
||||||
|
$query = User::query();
|
||||||
|
if ($q !== '') {
|
||||||
|
$query->where(function ($sub) use ($q): void {
|
||||||
|
$sub->where('email', 'like', '%'.$q.'%')
|
||||||
|
->orWhere('name', 'like', '%'.$q.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($tier !== '') {
|
||||||
|
$query->where('tier', $tier);
|
||||||
|
}
|
||||||
|
if ($role !== '') {
|
||||||
|
$query->where('role', $role);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($out, ['id', 'name', 'email', 'role', 'tier', 'created_at']);
|
||||||
|
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row->id,
|
||||||
|
$row->name,
|
||||||
|
$row->email,
|
||||||
|
$row->role,
|
||||||
|
$row->tier,
|
||||||
|
optional($row->created_at)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} elseif ($type === 'subscriptions') {
|
||||||
|
$userId = $request->query('user_id');
|
||||||
|
$email = $request->query('email');
|
||||||
|
$status = $request->query('status');
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'plan', 'status', 'started_at', 'expires_at', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
|
||||||
|
$query = Subscription::query()->with('user:id,email');
|
||||||
|
if ($userId) {
|
||||||
|
$query->where('user_id', (int) $userId);
|
||||||
|
}
|
||||||
|
if ($email) {
|
||||||
|
$query->whereHas('user', fn ($q) => $q->where('email', $email));
|
||||||
|
}
|
||||||
|
if ($status) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($out, ['id', 'user_id', 'email', 'plan', 'status', 'started_at', 'expires_at']);
|
||||||
|
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row->id,
|
||||||
|
$row->user_id,
|
||||||
|
$row->user?->email,
|
||||||
|
$row->plan,
|
||||||
|
$row->status,
|
||||||
|
optional($row->started_at)->toDateTimeString(),
|
||||||
|
optional($row->expires_at)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} elseif ($type === 'webhooks') {
|
||||||
|
$provider = $request->query('provider');
|
||||||
|
$status = $request->query('status');
|
||||||
|
$sort = $this->sanitizeSort($request->query('sort'), ['id', 'provider', 'status', 'received_at', 'created_at'], 'id');
|
||||||
|
$dir = $this->sanitizeDir($request->query('dir'));
|
||||||
|
|
||||||
|
$query = WebhookEvent::query();
|
||||||
|
if ($provider) {
|
||||||
|
$query->where('provider', (string) $provider);
|
||||||
|
}
|
||||||
|
if ($status) {
|
||||||
|
$query->where('status', (string) $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
fputcsv($out, ['id', 'provider', 'event_type', 'status', 'received_at', 'processed_at']);
|
||||||
|
$query->orderBy($sort, $dir)->chunk(500, function ($rows) use ($out): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row->id,
|
||||||
|
$row->provider,
|
||||||
|
$row->event_type,
|
||||||
|
$row->status,
|
||||||
|
optional($row->received_at)->toDateTimeString(),
|
||||||
|
optional($row->processed_at)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fputcsv($out, ['error']);
|
||||||
|
fputcsv($out, ['unsupported_export_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($out);
|
||||||
|
}, $filename, [
|
||||||
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logAdminAction(string $action, array $payload = []): void
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('admin_audit_logs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$user = auth()->user();
|
||||||
|
AdminAuditLog::create([
|
||||||
|
'admin_id' => $user?->id,
|
||||||
|
'admin_email' => $user?->email,
|
||||||
|
'action' => $action,
|
||||||
|
'payload' => $payload,
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeSort(mixed $value, array $allowed, string $fallback): string
|
||||||
|
{
|
||||||
|
$sort = is_string($value) ? $value : '';
|
||||||
|
return in_array($sort, $allowed, true) ? $sort : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeDir(mixed $value): string
|
||||||
|
{
|
||||||
|
return $value === 'asc' ? 'asc' : 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private function splitCsv(string $value): array
|
||||||
|
{
|
||||||
|
$items = array_filter(array_map('trim', explode(',', $value)));
|
||||||
|
return array_values($items);
|
||||||
|
}
|
||||||
|
}
|
||||||
452
app/app/Http/Controllers/Dashboard/UserDashboardController.php
Normal file
452
app/app/Http/Controllers/Dashboard/UserDashboardController.php
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Dashboard;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\Payment;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use App\Models\UserKeyword;
|
||||||
|
use App\Models\WebhookEvent;
|
||||||
|
use App\Http\Controllers\Api\V1\EmojiApiController;
|
||||||
|
use App\Services\Auth\ApiKeyService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
|
class UserDashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiKeyService $keys
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function overview(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (Gate::allows('admin')) {
|
||||||
|
$days = 7;
|
||||||
|
$start = now()->subDays($days - 1)->startOfDay();
|
||||||
|
$labels = [];
|
||||||
|
$values = [];
|
||||||
|
$subsValues = [];
|
||||||
|
$webhookValues = [];
|
||||||
|
|
||||||
|
$rawUsers = DB::table('users')
|
||||||
|
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get()
|
||||||
|
->keyBy('day');
|
||||||
|
|
||||||
|
$rawSubs = DB::table('subscriptions')
|
||||||
|
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get()
|
||||||
|
->keyBy('day');
|
||||||
|
|
||||||
|
$rawWebhooks = DB::table('webhook_events')
|
||||||
|
->selectRaw('DATE(COALESCE(received_at, created_at)) as day, COUNT(*) as total')
|
||||||
|
->where('created_at', '>=', $start)
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get()
|
||||||
|
->keyBy('day');
|
||||||
|
|
||||||
|
for ($i = $days - 1; $i >= 0; $i--) {
|
||||||
|
$date = Carbon::now()->subDays($i)->format('Y-m-d');
|
||||||
|
$labels[] = Carbon::parse($date)->format('M d');
|
||||||
|
$values[] = (int) ($rawUsers[$date]->total ?? 0);
|
||||||
|
$subsValues[] = (int) ($rawSubs[$date]->total ?? 0);
|
||||||
|
$webhookValues[] = (int) ($rawWebhooks[$date]->total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersTotal = User::count();
|
||||||
|
$usersPersonal = User::where('tier', 'personal')->count();
|
||||||
|
$subscriptionsActive = Subscription::where('status', 'active')->count();
|
||||||
|
$subscriptionsTotal = Subscription::count();
|
||||||
|
$webhookTotal = WebhookEvent::count();
|
||||||
|
$webhookErrors = WebhookEvent::where('status', 'error')->count();
|
||||||
|
|
||||||
|
return view('dashboard.index', [
|
||||||
|
'chartLabels' => $labels,
|
||||||
|
'chartValues' => $values,
|
||||||
|
'chartSubs' => $subsValues,
|
||||||
|
'chartWebhooks' => $webhookValues,
|
||||||
|
'overviewMetrics' => [
|
||||||
|
'users_total' => $usersTotal,
|
||||||
|
'users_personal' => $usersPersonal,
|
||||||
|
'subscriptions_active' => $subscriptionsActive,
|
||||||
|
'subscriptions_total' => $subscriptionsTotal,
|
||||||
|
'webhook_total' => $webhookTotal,
|
||||||
|
'webhook_errors' => $webhookErrors,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$recentKeywords = UserKeyword::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(6)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recentWeekCount = UserKeyword::where('user_id', $user?->id)
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalKeywords = UserKeyword::where('user_id', $user?->id)->count();
|
||||||
|
$apiKeyCount = UserApiKey::where('user_id', $user?->id)
|
||||||
|
->whereNull('revoked_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$activeSubscription = Subscription::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('started_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return view('dashboard.user.overview', [
|
||||||
|
'totalKeywords' => $totalKeywords,
|
||||||
|
'recentKeywords' => $recentKeywords,
|
||||||
|
'recentWeekCount' => $recentWeekCount,
|
||||||
|
'apiKeyCount' => $apiKeyCount,
|
||||||
|
'subscription' => $activeSubscription,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function keywords(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$items = UserKeyword::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$emojiLookup = [];
|
||||||
|
$dataPath = (string) config('dewemoji.data_path');
|
||||||
|
if (is_file($dataPath)) {
|
||||||
|
$raw = file_get_contents($dataPath);
|
||||||
|
$decoded = json_decode((string) $raw, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
foreach ($decoded['emojis'] ?? [] as $row) {
|
||||||
|
$slug = (string) ($row['slug'] ?? '');
|
||||||
|
if ($slug !== '') {
|
||||||
|
$emojiLookup[$slug] = [
|
||||||
|
'emoji' => (string) ($row['emoji'] ?? ''),
|
||||||
|
'name' => (string) ($row['name'] ?? $slug),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('dashboard.user.keywords', [
|
||||||
|
'items' => $items,
|
||||||
|
'user' => $user,
|
||||||
|
'emojiLookup' => $emojiLookup,
|
||||||
|
'freeLimit' => $this->keywordLimitFor($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function keywordSearch(Request $request, EmojiApiController $emoji): JsonResponse
|
||||||
|
{
|
||||||
|
$request->query->set('private', 'true');
|
||||||
|
|
||||||
|
return $emoji->search($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeKeyword(Request $request): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'emoji_slug' => 'required|string|max:120',
|
||||||
|
'keyword' => 'required|string|max:200',
|
||||||
|
'lang' => 'nullable|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($limit = $this->keywordLimitFor($user)) {
|
||||||
|
$exists = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $data['emoji_slug'])
|
||||||
|
->where('keyword', $data['keyword'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (!$exists) {
|
||||||
|
$count = UserKeyword::where('user_id', $user->id)->count();
|
||||||
|
if ($count >= $limit) {
|
||||||
|
return $this->rejectKeywordLimit($request, $limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = UserKeyword::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji_slug' => $data['emoji_slug'],
|
||||||
|
'keyword' => $data['keyword'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lang' => $data['lang'] ?? 'und',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['ok' => true, 'item' => $item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', 'Keyword saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyword->user_id !== $user->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'emoji_slug' => 'required|string|max:120',
|
||||||
|
'keyword' => 'required|string|max:200',
|
||||||
|
'lang' => 'nullable|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $data['emoji_slug'])
|
||||||
|
->where('keyword', $data['keyword'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($duplicate && $duplicate->id !== $keyword->id) {
|
||||||
|
$keyword->delete();
|
||||||
|
$keyword = $duplicate;
|
||||||
|
} else {
|
||||||
|
$keyword->update([
|
||||||
|
'emoji_slug' => $data['emoji_slug'],
|
||||||
|
'keyword' => $data['keyword'],
|
||||||
|
'lang' => $data['lang'] ?? 'und',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['ok' => true, 'item' => $keyword]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', 'Keyword updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKeyword(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyword->user_id !== $user->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyword->delete();
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', 'Keyword removed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function importKeywords(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->input('payload');
|
||||||
|
if ($request->hasFile('file')) {
|
||||||
|
$payload = $request->file('file')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = json_decode((string) $payload, true);
|
||||||
|
if (!is_array($items)) {
|
||||||
|
return back()->withErrors(['payload' => 'Invalid JSON payload.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$limit = $this->keywordLimitFor($user);
|
||||||
|
$current = UserKeyword::where('user_id', $user->id)->count();
|
||||||
|
foreach ($items as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$emojiSlug = trim((string) ($row['emoji_slug'] ?? ''));
|
||||||
|
$keyword = trim((string) ($row['keyword'] ?? ''));
|
||||||
|
$lang = trim((string) ($row['lang'] ?? 'und'));
|
||||||
|
if ($emojiSlug === '' || $keyword === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$exists = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $emojiSlug)
|
||||||
|
->where('keyword', $keyword)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (!$exists && $limit !== null && $current >= $limit) {
|
||||||
|
$skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserKeyword::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji_slug' => $emojiSlug,
|
||||||
|
'keyword' => $keyword,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lang' => $lang !== '' ? $lang : 'und',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$exists) {
|
||||||
|
$current += 1;
|
||||||
|
}
|
||||||
|
$imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$message = "Imported {$imported} keywords.";
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$message .= " {$skipped} skipped (free limit reached).";
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportKeywords(Request $request): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = UserKeyword::where('user_id', $user->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(['emoji_slug', 'keyword', 'lang'])
|
||||||
|
->values()
|
||||||
|
->toJson(JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
$filename = 'dewemoji-keywords-'.$user->id.'-'.now()->format('Ymd-His').'.json';
|
||||||
|
$path = storage_path('app/'.$filename);
|
||||||
|
file_put_contents($path, $items);
|
||||||
|
|
||||||
|
return response()->download($path, $filename)->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiKeys(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$keys = UserApiKey::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
$canCreate = $user && (string) $user->tier === 'personal';
|
||||||
|
|
||||||
|
return view('dashboard.user.api-keys', [
|
||||||
|
'user' => $user,
|
||||||
|
'keys' => $keys,
|
||||||
|
'canCreate' => $canCreate,
|
||||||
|
'newKey' => session('new_api_key'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createApiKey(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
if ((string) $user->tier !== 'personal') {
|
||||||
|
return back()->withErrors(['api_key' => 'API keys are available on the Personal plan.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'nullable|string|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$issued = $this->keys->issueKey($user, $data['name'] ?? null);
|
||||||
|
|
||||||
|
return back()->with('new_api_key', $issued['plain']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeApiKey(Request $request, UserApiKey $key): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || $key->user_id !== $user->id) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$key->update(['revoked_at' => Carbon::now()]);
|
||||||
|
|
||||||
|
return back()->with('status', 'API key revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function billing(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$subscription = Subscription::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('started_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$orders = Order::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$payments = Payment::where('user_id', $user?->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('dashboard.user.billing', [
|
||||||
|
'subscription' => $subscription,
|
||||||
|
'user' => $user,
|
||||||
|
'orders' => $orders,
|
||||||
|
'payments' => $payments,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preferences(Request $request): View
|
||||||
|
{
|
||||||
|
return view('dashboard.user.preferences');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rejectKeywordLimit(Request $request, int $limit): RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->withErrors(['tier' => "Free plan limit reached ({$limit} keywords)."]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keywordLimitFor(?User $user): ?int
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((string) $user->tier === 'personal') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) config('dewemoji.pagination.free_max_limit', 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/app/Http/Controllers/ProfileController.php
Normal file
66
app/app/Http/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the user's profile form.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): View
|
||||||
|
{
|
||||||
|
return view('dashboard.user.profile', [
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
$emailChanged = $request->user()->isDirty('email');
|
||||||
|
if ($emailChanged) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
if ($emailChanged && $request->user() instanceof MustVerifyEmail) {
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's account.
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validateWithBag('userDeletion', [
|
||||||
|
'password' => ['required', 'current_password'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return Redirect::to('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use App\Models\UserKeyword;
|
||||||
|
use App\Services\System\SettingsService;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -23,6 +26,12 @@ class SiteController extends Controller
|
|||||||
'Flags' => 'flags',
|
'Flags' => 'flags',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private function billingMode(): string
|
||||||
|
{
|
||||||
|
$settings = app(SettingsService::class);
|
||||||
|
return (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
|
||||||
|
}
|
||||||
|
|
||||||
public function home(Request $request): View
|
public function home(Request $request): View
|
||||||
{
|
{
|
||||||
return view('site.home', [
|
return view('site.home', [
|
||||||
@@ -30,6 +39,7 @@ class SiteController extends Controller
|
|||||||
'initialCategory' => trim((string) $request->query('category', '')),
|
'initialCategory' => trim((string) $request->query('category', '')),
|
||||||
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
|
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
|
||||||
'canonicalPath' => '/',
|
'canonicalPath' => '/',
|
||||||
|
'userTier' => $request->user()?->tier,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,19 +55,21 @@ class SiteController extends Controller
|
|||||||
'initialCategory' => trim((string) $request->query('category', '')),
|
'initialCategory' => trim((string) $request->query('category', '')),
|
||||||
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
|
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
|
||||||
'canonicalPath' => '/browse',
|
'canonicalPath' => '/browse',
|
||||||
|
'userTier' => $request->user()?->tier,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function category(string $categorySlug): View
|
public function category(string $categorySlug): View
|
||||||
{
|
{
|
||||||
if ($categorySlug === 'all') {
|
if ($categorySlug === 'all') {
|
||||||
return view('site.home', [
|
return view('site.home', [
|
||||||
'initialQuery' => '',
|
'initialQuery' => '',
|
||||||
'initialCategory' => '',
|
'initialCategory' => '',
|
||||||
'initialSubcategory' => '',
|
'initialSubcategory' => '',
|
||||||
'canonicalPath' => '/',
|
'canonicalPath' => '/',
|
||||||
]);
|
'userTier' => request()->user()?->tier,
|
||||||
}
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
|
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
|
||||||
abort_if($categoryLabel === '', 404);
|
abort_if($categoryLabel === '', 404);
|
||||||
@@ -67,6 +79,7 @@ class SiteController extends Controller
|
|||||||
'initialCategory' => $categoryLabel,
|
'initialCategory' => $categoryLabel,
|
||||||
'initialSubcategory' => '',
|
'initialSubcategory' => '',
|
||||||
'canonicalPath' => '/'.$categorySlug,
|
'canonicalPath' => '/'.$categorySlug,
|
||||||
|
'userTier' => request()->user()?->tier,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +97,7 @@ class SiteController extends Controller
|
|||||||
'initialCategory' => $categoryLabel,
|
'initialCategory' => $categoryLabel,
|
||||||
'initialSubcategory' => $subcategorySlug,
|
'initialSubcategory' => $subcategorySlug,
|
||||||
'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug,
|
'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug,
|
||||||
|
'userTier' => request()->user()?->tier,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +108,96 @@ class SiteController extends Controller
|
|||||||
|
|
||||||
public function pricing(): View
|
public function pricing(): View
|
||||||
{
|
{
|
||||||
return view('site.pricing');
|
$currencyPref = strtoupper((string) session('pricing_currency', ''));
|
||||||
|
if (!in_array($currencyPref, ['IDR', 'USD'], true)) {
|
||||||
|
$currencyPref = $this->detectPricingCurrency(request());
|
||||||
|
session(['pricing_currency' => $currencyPref]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
|
||||||
|
$plans = PricingPlan::where('status', 'active')->get()->keyBy('code');
|
||||||
|
$defaults = config('dewemoji.pricing.defaults', []);
|
||||||
|
$fallback = collect($defaults)->keyBy('code');
|
||||||
|
|
||||||
|
$getPlanAmount = function (string $code) use ($plans, $fallback): int {
|
||||||
|
$plan = $plans->get($code) ?? $fallback->get($code);
|
||||||
|
return (int) ($plan['amount'] ?? $plan->amount ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
$pricing = [
|
||||||
|
'personal_monthly' => [
|
||||||
|
'idr' => $getPlanAmount('personal_monthly'),
|
||||||
|
],
|
||||||
|
'personal_annual' => [
|
||||||
|
'idr' => $getPlanAmount('personal_annual'),
|
||||||
|
],
|
||||||
|
'personal_lifetime' => [
|
||||||
|
'idr' => $getPlanAmount('personal_lifetime'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($pricing as $key => $row) {
|
||||||
|
$pricing[$key]['usd'] = $rate > 0 ? round($row['idr'] / $rate, 2) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('site.pricing', [
|
||||||
|
'currencyPref' => $currencyPref,
|
||||||
|
'usdRate' => $rate,
|
||||||
|
'pricing' => $pricing,
|
||||||
|
'payments' => [
|
||||||
|
'qris_url' => (string) config('dewemoji.payments.qris_url', ''),
|
||||||
|
'paypal_url' => (string) config('dewemoji.payments.paypal_url', ''),
|
||||||
|
],
|
||||||
|
'pakasirEnabled' => (bool) config('dewemoji.billing.providers.pakasir.enabled', false)
|
||||||
|
&& (string) config('dewemoji.billing.providers.pakasir.api_base', '') !== ''
|
||||||
|
&& (string) config('dewemoji.billing.providers.pakasir.api_key', '') !== ''
|
||||||
|
&& (string) config('dewemoji.billing.providers.pakasir.project', '') !== '',
|
||||||
|
'paypalEnabled' => $this->paypalEnabled($this->billingMode()),
|
||||||
|
'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paypalEnabled(string $mode): bool
|
||||||
|
{
|
||||||
|
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
|
||||||
|
$clientId = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_id", '');
|
||||||
|
$clientSecret = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_secret", '');
|
||||||
|
$apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base", '');
|
||||||
|
|
||||||
|
return $enabled && $clientId !== '' && $clientSecret !== '' && $apiBase !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paypalPlanAvailability(string $mode): array
|
||||||
|
{
|
||||||
|
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get()->keyBy('code');
|
||||||
|
|
||||||
|
$fromDb = function (string $code) use ($plans, $mode): bool {
|
||||||
|
$plan = $plans->get($code);
|
||||||
|
if (!$plan) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$meta = $plan->meta ?? [];
|
||||||
|
return (string) ($meta['paypal'][$mode]['plan']['id'] ?? '') !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
$fromEnv = function (string $code) use ($mode): bool {
|
||||||
|
return (string) config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$code}", '') !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'personal_monthly' => $fromDb('personal_monthly') || $fromEnv('personal_monthly'),
|
||||||
|
'personal_annual' => $fromDb('personal_annual') || $fromEnv('personal_annual'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPricingCurrency(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'currency' => 'required|string|in:IDR,USD',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session(['pricing_currency' => $data['currency']]);
|
||||||
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function support(): View
|
public function support(): View
|
||||||
@@ -112,6 +215,18 @@ class SiteController extends Controller
|
|||||||
return view('site.terms');
|
return view('site.terms');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function detectPricingCurrency(Request $request): string
|
||||||
|
{
|
||||||
|
$country = strtoupper((string) ($request->header('CF-IPCountry')
|
||||||
|
?? $request->header('X-Country-Code')
|
||||||
|
?? $request->header('X-Geo-Country')
|
||||||
|
?? $request->header('X-Appengine-Country')
|
||||||
|
?? $request->header('CloudFront-Viewer-Country')
|
||||||
|
?? ''));
|
||||||
|
|
||||||
|
return $country === 'ID' ? 'IDR' : 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
public function emojiDetail(string $slug): View|Response
|
public function emojiDetail(string $slug): View|Response
|
||||||
{
|
{
|
||||||
$dataPath = (string) config('dewemoji.data_path');
|
$dataPath = (string) config('dewemoji.data_path');
|
||||||
@@ -157,10 +272,22 @@ class SiteController extends Controller
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = request()->user();
|
||||||
|
$isPersonal = $user && (string) $user->tier === 'personal';
|
||||||
|
$userKeywords = [];
|
||||||
|
if ($isPersonal) {
|
||||||
|
$userKeywords = UserKeyword::where('user_id', $user->id)
|
||||||
|
->where('emoji_slug', $slug)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
return view('site.emoji-detail', [
|
return view('site.emoji-detail', [
|
||||||
'emoji' => $match,
|
'emoji' => $match,
|
||||||
'relatedDetails' => $relatedDetails,
|
'relatedDetails' => $relatedDetails,
|
||||||
'canonicalPath' => '/emoji/'.$slug,
|
'canonicalPath' => '/emoji/'.$slug,
|
||||||
|
'userKeywords' => $userKeywords,
|
||||||
|
'userTier' => $user?->tier,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
85
app/app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/app/Http/Requests/Auth/LoginRequest.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to authenticate the request's credentials.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
$this->ensureIsNotRateLimited();
|
||||||
|
|
||||||
|
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||||
|
RateLimiter::hit($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.failed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($this->throttleKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the login request is not rate limited.
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function ensureIsNotRateLimited(): void
|
||||||
|
{
|
||||||
|
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event(new Lockout($this));
|
||||||
|
|
||||||
|
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => trans('auth.throttle', [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'minutes' => ceil($seconds / 60),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limiting throttle key for the request.
|
||||||
|
*/
|
||||||
|
public function throttleKey(): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/app/Http/Requests/ProfileUpdateRequest.php
Normal file
30
app/app/Http/Requests/ProfileUpdateRequest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'lowercase',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/app/Mail/MailketingTransport.php
Normal file
88
app/app/Mail/MailketingTransport.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportException;
|
||||||
|
use Symfony\Component\Mailer\SentMessage;
|
||||||
|
use Symfony\Component\Mailer\Transport\AbstractTransport;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
|
||||||
|
class MailketingTransport extends AbstractTransport
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected string $apiToken,
|
||||||
|
protected string $endpoint,
|
||||||
|
protected ?string $defaultFromName = null,
|
||||||
|
protected ?string $defaultFromEmail = null,
|
||||||
|
protected int $timeoutSeconds = 10,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return 'mailketing';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function doSend(SentMessage $message): void
|
||||||
|
{
|
||||||
|
$email = $message->getOriginalMessage();
|
||||||
|
|
||||||
|
if (! $email instanceof Email) {
|
||||||
|
throw new TransportException('Mailketing transport only supports Symfony Email messages.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($email->getAttachments()) {
|
||||||
|
throw new TransportException('Mailketing transport does not support attachments without URL references.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $email->getFrom()[0] ?? null;
|
||||||
|
$fromName = $from?->getName() ?: $this->defaultFromName ?: '';
|
||||||
|
$fromEmail = $from?->getAddress() ?: $this->defaultFromEmail ?: '';
|
||||||
|
|
||||||
|
if ($fromEmail === '') {
|
||||||
|
throw new TransportException('Mailketing transport requires a from email address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = $email->getSubject() ?? '';
|
||||||
|
$content = $email->getHtmlBody() ?? $email->getTextBody() ?? '';
|
||||||
|
|
||||||
|
$recipients = array_merge(
|
||||||
|
$email->getTo(),
|
||||||
|
$email->getCc(),
|
||||||
|
$email->getBcc(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($recipients === []) {
|
||||||
|
throw new TransportException('Mailketing transport requires at least one recipient.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout($this->timeoutSeconds)
|
||||||
|
->post($this->endpoint, [
|
||||||
|
'api_token' => $this->apiToken,
|
||||||
|
'from_name' => $fromName,
|
||||||
|
'from_email' => $fromEmail,
|
||||||
|
'recipient' => $recipient->getAddress(),
|
||||||
|
'subject' => $subject,
|
||||||
|
'content' => $content,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->ok()) {
|
||||||
|
throw new TransportException(sprintf(
|
||||||
|
'Mailketing request failed (%s): %s',
|
||||||
|
$response->status(),
|
||||||
|
$response->body()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response->json();
|
||||||
|
if (is_array($payload) && (($payload['status'] ?? null) !== 'success')) {
|
||||||
|
$detail = $payload['response'] ?? $payload['message'] ?? 'unknown';
|
||||||
|
throw new TransportException('Mailketing responded with an error: '.$detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/app/Mail/TestMailketing.php
Normal file
23
app/app/Mail/TestMailketing.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TestMailketing extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function build(): self
|
||||||
|
{
|
||||||
|
$actionUrl = config('app.url', 'https://dewemoji.com');
|
||||||
|
|
||||||
|
return $this
|
||||||
|
->subject('Dewemoji test email')
|
||||||
|
->view('emails.test', [
|
||||||
|
'actionUrl' => $actionUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/app/Models/AdminAuditLog.php
Normal file
20
app/app/Models/AdminAuditLog.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AdminAuditLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'admin_id',
|
||||||
|
'admin_email',
|
||||||
|
'action',
|
||||||
|
'payload',
|
||||||
|
'ip_address',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
31
app/app/Models/Order.php
Normal file
31
app/app/Models/Order.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Order extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'plan_code',
|
||||||
|
'type',
|
||||||
|
'currency',
|
||||||
|
'amount',
|
||||||
|
'status',
|
||||||
|
'provider',
|
||||||
|
'provider_ref',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Payment::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/app/Models/Payment.php
Normal file
36
app/app/Models/Payment.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Payment extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'order_id',
|
||||||
|
'provider',
|
||||||
|
'type',
|
||||||
|
'plan_code',
|
||||||
|
'currency',
|
||||||
|
'amount',
|
||||||
|
'status',
|
||||||
|
'provider_ref',
|
||||||
|
'raw_payload',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'raw_payload' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function order(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Order::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/app/Models/PricingChange.php
Normal file
19
app/app/Models/PricingChange.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PricingChange extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'admin_ref',
|
||||||
|
'before',
|
||||||
|
'after',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'before' => 'array',
|
||||||
|
'after' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
23
app/app/Models/PricingPlan.php
Normal file
23
app/app/Models/PricingPlan.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PricingPlan extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'name',
|
||||||
|
'currency',
|
||||||
|
'amount',
|
||||||
|
'period',
|
||||||
|
'status',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'integer',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
18
app/app/Models/Setting.php
Normal file
18
app/app/Models/Setting.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Setting extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
33
app/app/Models/Subscription.php
Normal file
33
app/app/Models/Subscription.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Subscription extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'plan',
|
||||||
|
'status',
|
||||||
|
'provider',
|
||||||
|
'provider_ref',
|
||||||
|
'started_at',
|
||||||
|
'expires_at',
|
||||||
|
'canceled_at',
|
||||||
|
'next_renewal_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'canceled_at' => 'datetime',
|
||||||
|
'next_renewal_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
||||||
|
use Illuminate\Auth\MustVerifyEmail;
|
||||||
|
use App\Notifications\ResetPasswordNotification;
|
||||||
|
use App\Notifications\VerifyEmailNotification;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmailContract
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, MustVerifyEmail;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -21,6 +24,8 @@ class User extends Authenticatable
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'role',
|
||||||
|
'tier',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,4 +50,19 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendEmailVerificationNotification(): void
|
||||||
|
{
|
||||||
|
$this->notify(new VerifyEmailNotification());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendPasswordResetNotification($token): void
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/app/Models/UserApiKey.php
Normal file
28
app/app/Models/UserApiKey.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserApiKey extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'key_hash',
|
||||||
|
'key_prefix',
|
||||||
|
'name',
|
||||||
|
'last_used_at',
|
||||||
|
'revoked_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
'revoked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/app/Models/UserKeyword.php
Normal file
21
app/app/Models/UserKeyword.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserKeyword extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'emoji_slug',
|
||||||
|
'keyword',
|
||||||
|
'lang',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/app/Models/WebhookEvent.php
Normal file
27
app/app/Models/WebhookEvent.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class WebhookEvent extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'provider',
|
||||||
|
'event_id',
|
||||||
|
'event_type',
|
||||||
|
'status',
|
||||||
|
'payload',
|
||||||
|
'headers',
|
||||||
|
'error',
|
||||||
|
'received_at',
|
||||||
|
'processed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'headers' => 'array',
|
||||||
|
'received_at' => 'datetime',
|
||||||
|
'processed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
20
app/app/Notifications/ResetPasswordNotification.php
Normal file
20
app/app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends ResetPassword
|
||||||
|
{
|
||||||
|
public function toMail($notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$resetUrl = $this->resetUrl($notifiable);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Reset your password')
|
||||||
|
->view('emails.reset-password', [
|
||||||
|
'resetUrl' => $resetUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/app/Notifications/VerifyEmailNotification.php
Normal file
20
app/app/Notifications/VerifyEmailNotification.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class VerifyEmailNotification extends VerifyEmail
|
||||||
|
{
|
||||||
|
public function toMail($notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$verificationUrl = $this->verificationUrl($notifiable);
|
||||||
|
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject('Verify your email')
|
||||||
|
->view('emails.verify-email', [
|
||||||
|
'verificationUrl' => $verificationUrl,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use App\Mail\MailketingTransport;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Gate::define('admin', function ($user) {
|
||||||
|
return $user && method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
Mail::extend('mailketing', function (array $config) {
|
||||||
|
return new MailketingTransport(
|
||||||
|
apiToken: (string) ($config['token'] ?? ''),
|
||||||
|
endpoint: (string) ($config['endpoint'] ?? 'https://api.mailketing.co.id/api/v1/send'),
|
||||||
|
defaultFromName: config('mail.from.name'),
|
||||||
|
defaultFromEmail: config('mail.from.address'),
|
||||||
|
timeoutSeconds: (int) ($config['timeout'] ?? 10),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
app/app/Services/Auth/ApiKeyService.php
Normal file
76
app/app/Services/Auth/ApiKeyService.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ApiKeyService
|
||||||
|
{
|
||||||
|
public function parseKey(Request $request): string
|
||||||
|
{
|
||||||
|
$auth = trim((string) $request->header('Authorization', ''));
|
||||||
|
if (str_starts_with($auth, 'Bearer ')) {
|
||||||
|
return trim(substr($auth, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim((string) $request->header('X-Api-Key', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hashKey(string $key): string
|
||||||
|
{
|
||||||
|
return hash('sha256', $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prefix(string $key): string
|
||||||
|
{
|
||||||
|
return substr($key, 0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveUser(Request $request): ?User
|
||||||
|
{
|
||||||
|
$key = $this->parseKey($request);
|
||||||
|
if ($key === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = UserApiKey::where('key_hash', $this->hashKey($key))
|
||||||
|
->whereNull('revoked_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->last_used_at = Carbon::now();
|
||||||
|
$record->save();
|
||||||
|
|
||||||
|
$user = $record->user;
|
||||||
|
if (!$user || (string) $user->tier !== 'personal') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function issueKey(User $user, ?string $name = null): array
|
||||||
|
{
|
||||||
|
$plain = 'dew_'.bin2hex(random_bytes(16));
|
||||||
|
$hash = $this->hashKey($plain);
|
||||||
|
$prefix = $this->prefix($plain);
|
||||||
|
|
||||||
|
$record = UserApiKey::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'key_hash' => $hash,
|
||||||
|
'key_prefix' => $prefix,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'plain' => $plain,
|
||||||
|
'record' => $record,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
319
app/app/Services/Billing/PayPalPlanSyncService.php
Normal file
319
app/app/Services/Billing/PayPalPlanSyncService.php
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PayPalPlanSyncService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{created:int,updated:int,deactivated:int,skipped:int}
|
||||||
|
*/
|
||||||
|
public function sync(string $mode = 'sandbox'): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'created' => 0,
|
||||||
|
'updated' => 0,
|
||||||
|
'deactivated' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$token = $this->getAccessToken($mode);
|
||||||
|
if (!$token) {
|
||||||
|
Log::warning('PayPal plan sync aborted: missing access token', ['mode' => $mode]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productId = $this->ensureProduct($mode, $token);
|
||||||
|
if (!$productId) {
|
||||||
|
Log::warning('PayPal plan sync aborted: missing product id', ['mode' => $mode]);
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get();
|
||||||
|
$keepIds = [];
|
||||||
|
foreach ($plans as $plan) {
|
||||||
|
$currentMeta = $plan->meta ?? [];
|
||||||
|
$currentPlanId = $currentMeta['paypal'][$mode]['plan']['id'] ?? null;
|
||||||
|
$history = $currentMeta['paypal'][$mode]['plan']['history'] ?? [];
|
||||||
|
if ($currentPlanId) {
|
||||||
|
$history = array_values(array_unique(array_merge([$currentPlanId], $history)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$amountUsd = $this->toUsdAmount($plan->amount);
|
||||||
|
$existing = $currentPlanId ? $this->getPlan($mode, $token, $currentPlanId) : null;
|
||||||
|
$existingAmount = $existing['billing_cycles'][0]['pricing_scheme']['fixed_price']['value'] ?? null;
|
||||||
|
|
||||||
|
if ($existing && $existingAmount !== null && (float) $existingAmount === (float) $amountUsd) {
|
||||||
|
$currentMeta['paypal'][$mode]['plan'] = [
|
||||||
|
'id' => $currentPlanId,
|
||||||
|
'amount_usd' => $amountUsd,
|
||||||
|
'history' => $history,
|
||||||
|
'synced_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
$plan->update(['meta' => $currentMeta]);
|
||||||
|
if ($currentPlanId) {
|
||||||
|
$keepIds[] = $currentPlanId;
|
||||||
|
}
|
||||||
|
$result['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newPlanId = $this->createPlan($mode, $token, $productId, $plan->code, $plan->name, $amountUsd, $plan->period);
|
||||||
|
if (!$newPlanId) {
|
||||||
|
$result['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentMeta['paypal'][$mode]['plan'] = [
|
||||||
|
'id' => $newPlanId,
|
||||||
|
'amount_usd' => $amountUsd,
|
||||||
|
'history' => $history,
|
||||||
|
'synced_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$plan->update(['meta' => $currentMeta]);
|
||||||
|
if ($currentPlanId) {
|
||||||
|
$result['updated']++;
|
||||||
|
} else {
|
||||||
|
$result['created']++;
|
||||||
|
}
|
||||||
|
$keepIds[] = $newPlanId;
|
||||||
|
|
||||||
|
if ($this->canDeactivate($plan->code)) {
|
||||||
|
foreach ($history as $oldId) {
|
||||||
|
if ($oldId === $newPlanId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->deactivatePlan($mode, $token, $oldId)) {
|
||||||
|
$result['deactivated']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate any other active plans under this product to avoid confusion
|
||||||
|
$deactivated = $this->deactivateOtherPlansForProduct($mode, $token, $productId, $keepIds);
|
||||||
|
$result['deactivated'] += $deactivated;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toUsdAmount(int $idrAmount): string
|
||||||
|
{
|
||||||
|
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
|
||||||
|
$usd = $rate > 0 ? $idrAmount / $rate : 0;
|
||||||
|
return number_format(max($usd, 1), 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canDeactivate(string $planCode): bool
|
||||||
|
{
|
||||||
|
$activeCount = \App\Models\Subscription::query()
|
||||||
|
->where('provider', 'paypal')
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('plan', $planCode)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $activeCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAccessToken(string $mode): ?string
|
||||||
|
{
|
||||||
|
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
|
||||||
|
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
|
||||||
|
if (!$clientId || !$clientSecret || !$apiBase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = Http::asForm()
|
||||||
|
->withBasicAuth($clientId, $clientSecret)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/oauth2/token', [
|
||||||
|
'grant_type' => 'client_credentials',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$res->ok()) {
|
||||||
|
Log::warning('PayPal auth failed', ['body' => $res->body()]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->json('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureProduct(string $mode, string $token): ?string
|
||||||
|
{
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$list = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->get(rtrim($apiBase, '/').'/v1/catalogs/products', [
|
||||||
|
'page_size' => 20,
|
||||||
|
'page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($list->ok()) {
|
||||||
|
$items = $list->json('products') ?? [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (($item['custom_id'] ?? '') === 'dewemoji-personal') {
|
||||||
|
return $item['id'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'name' => 'Dewemoji Personal',
|
||||||
|
'type' => 'SERVICE',
|
||||||
|
'category' => 'SOFTWARE',
|
||||||
|
'custom_id' => 'dewemoji-personal',
|
||||||
|
];
|
||||||
|
|
||||||
|
$create = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/catalogs/products', $payload);
|
||||||
|
$createdId = $create->json('id');
|
||||||
|
if ($createdId) {
|
||||||
|
if (!$create->ok()) {
|
||||||
|
Log::warning('PayPal product create returned non-OK but provided id', [
|
||||||
|
'status' => $create->status(),
|
||||||
|
'body' => $create->body(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $createdId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('PayPal product create failed', [
|
||||||
|
'status' => $create->status(),
|
||||||
|
'body' => $create->body(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPlan(
|
||||||
|
string $mode,
|
||||||
|
string $token,
|
||||||
|
string $productId,
|
||||||
|
string $code,
|
||||||
|
string $name,
|
||||||
|
string $amountUsd,
|
||||||
|
?string $period
|
||||||
|
): ?string {
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$intervalUnit = $period === 'year' ? 'YEAR' : 'MONTH';
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'product_id' => $productId,
|
||||||
|
'name' => $name,
|
||||||
|
'description' => "{$name} subscription",
|
||||||
|
'billing_cycles' => [
|
||||||
|
[
|
||||||
|
'frequency' => [
|
||||||
|
'interval_unit' => $intervalUnit,
|
||||||
|
'interval_count' => 1,
|
||||||
|
],
|
||||||
|
'tenure_type' => 'REGULAR',
|
||||||
|
'sequence' => 1,
|
||||||
|
'total_cycles' => 0,
|
||||||
|
'pricing_scheme' => [
|
||||||
|
'fixed_price' => [
|
||||||
|
'value' => $amountUsd,
|
||||||
|
'currency_code' => 'USD',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'payment_preferences' => [
|
||||||
|
'auto_bill_outstanding' => true,
|
||||||
|
'setup_fee_failure_action' => 'CONTINUE',
|
||||||
|
'payment_failure_threshold' => 3,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/billing/plans', $payload);
|
||||||
|
|
||||||
|
$planId = $res->json('id');
|
||||||
|
if ($planId) {
|
||||||
|
if (!$res->ok()) {
|
||||||
|
Log::warning('PayPal plan create returned non-OK but provided id', [
|
||||||
|
'code' => $code,
|
||||||
|
'status' => $res->status(),
|
||||||
|
'body' => $res->body(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $planId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('PayPal plan create failed', [
|
||||||
|
'code' => $code,
|
||||||
|
'status' => $res->status(),
|
||||||
|
'body' => $res->body(),
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getPlan(string $mode, string $token, string $planId): ?array
|
||||||
|
{
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->get(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId);
|
||||||
|
|
||||||
|
if (!$res->ok()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $res->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deactivatePlan(string $mode, string $token, string $planId): bool
|
||||||
|
{
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->post(rtrim($apiBase, '/').'/v1/billing/plans/'.$planId.'/deactivate');
|
||||||
|
|
||||||
|
return $res->ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deactivateOtherPlansForProduct(string $mode, string $token, string $productId, array $keepIds): int
|
||||||
|
{
|
||||||
|
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
|
||||||
|
$res = Http::withToken($token)
|
||||||
|
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
|
||||||
|
->get(rtrim($apiBase, '/').'/v1/billing/plans', [
|
||||||
|
'product_id' => $productId,
|
||||||
|
'page_size' => 20,
|
||||||
|
'page' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$res->ok()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $res->json('plans') ?? [];
|
||||||
|
if (!is_array($items)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($items as $plan) {
|
||||||
|
$id = $plan['id'] ?? null;
|
||||||
|
$status = strtoupper((string) ($plan['status'] ?? ''));
|
||||||
|
if (!$id || in_array($id, $keepIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($status !== 'ACTIVE') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->deactivatePlan($mode, $token, $id)) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/app/Services/Billing/PaypalWebhookProcessor.php
Normal file
78
app/app/Services/Billing/PaypalWebhookProcessor.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserApiKey;
|
||||||
|
|
||||||
|
class PaypalWebhookProcessor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*/
|
||||||
|
public function process(string $eventType, array $payload): void
|
||||||
|
{
|
||||||
|
$resource = $payload['resource'] ?? [];
|
||||||
|
if (!is_array($resource)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscriptionId = (string) ($resource['id'] ?? '');
|
||||||
|
$email = (string) ($resource['subscriber']['email_address'] ?? '');
|
||||||
|
|
||||||
|
if ($subscriptionId === '' || $email === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('email', $email)->first();
|
||||||
|
if (!$user) {
|
||||||
|
throw new \RuntimeException('User not found for webhook email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') {
|
||||||
|
Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan' => 'personal',
|
||||||
|
'status' => 'active',
|
||||||
|
'provider' => 'paypal',
|
||||||
|
'provider_ref' => $subscriptionId,
|
||||||
|
'started_at' => now(),
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
$user->update(['tier' => 'personal']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'BILLING.SUBSCRIPTION.CANCELLED' || $eventType === 'BILLING.SUBSCRIPTION.SUSPENDED') {
|
||||||
|
Subscription::where('user_id', $user->id)
|
||||||
|
->where('provider', 'paypal')
|
||||||
|
->where('provider_ref', $subscriptionId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->update([
|
||||||
|
'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended',
|
||||||
|
'expires_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncUserTier($user->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncUserTier(int $userId): void
|
||||||
|
{
|
||||||
|
$active = Subscription::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where(function ($q): void {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
User::where('id', $userId)->update([
|
||||||
|
'tier' => $active ? 'personal' : 'free',
|
||||||
|
]);
|
||||||
|
if (!$active) {
|
||||||
|
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/app/Services/Extension/ExtensionVerificationService.php
Normal file
60
app/app/Services/Extension/ExtensionVerificationService.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Extension;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class ExtensionVerificationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string> $expectedExtensionIds
|
||||||
|
*/
|
||||||
|
public function verifyToken(string $token, array $expectedExtensionIds): bool
|
||||||
|
{
|
||||||
|
if ($token === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = config('dewemoji.extension_verification', []);
|
||||||
|
if (!(bool) ($config['enabled'] ?? true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectId = (string) ($config['project_id'] ?? '');
|
||||||
|
$serverKey = (string) ($config['server_key'] ?? '');
|
||||||
|
if ($projectId === '' || $serverKey === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'dw_ext_verify_'.sha1($token);
|
||||||
|
$ttl = max((int) ($config['cache_ttl'] ?? 3600), 60);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, $ttl, function () use ($token, $serverKey, $expectedExtensionIds): bool {
|
||||||
|
$url = 'https://iid.googleapis.com/iid/info/'.$token.'?details=true';
|
||||||
|
$response = Http::withHeaders([
|
||||||
|
'Authorization' => 'key='.$serverKey,
|
||||||
|
])->get($url);
|
||||||
|
|
||||||
|
if (!$response->ok()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = (string) ($data['application'] ?? '');
|
||||||
|
if ($application === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($expectedExtensionIds) === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($application, $expectedExtensionIds, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/app/Services/System/SettingsService.php
Normal file
46
app/app/Services/System/SettingsService.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\System;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class SettingsService
|
||||||
|
{
|
||||||
|
private const CACHE_KEY = 'dw_settings_all';
|
||||||
|
private const CACHE_TTL = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function (): array {
|
||||||
|
$out = [];
|
||||||
|
foreach (Setting::all(['key', 'value']) as $setting) {
|
||||||
|
$out[$setting->key] = $setting->value;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$all = $this->all();
|
||||||
|
return array_key_exists($key, $all) ? $all[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $values
|
||||||
|
*/
|
||||||
|
public function setMany(array $values, ?string $updatedBy = null): void
|
||||||
|
{
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => (string) $key],
|
||||||
|
['value' => $value, 'updated_by' => $updatedBy]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Cache::forget(self::CACHE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/app/View/Components/AppLayout.php
Normal file
17
app/app/View/Components/AppLayout.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class AppLayout extends Component
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the view / contents that represents the component.
|
||||||
|
*/
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/app/View/Components/GuestLayout.php
Normal file
17
app/app/View/Components/GuestLayout.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\View\Components;
|
||||||
|
|
||||||
|
use Illuminate\View\Component;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class GuestLayout extends Component
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the view / contents that represents the component.
|
||||||
|
*/
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('layouts.guest');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: [
|
||||||
|
__DIR__.'/../routes/web.php',
|
||||||
|
__DIR__.'/../routes/dashboard.php',
|
||||||
|
],
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
apiPrefix: '',
|
apiPrefix: '',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/breeze": "*",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
|
|||||||
63
app/composer.lock
generated
63
app/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "79cd7bb4d53c32c6fa51bec0f240bd28",
|
"content-hash": "4f99c59819e467f4dda33565265ea286",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -6182,6 +6182,67 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"time": "2025-04-30T06:54:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/breeze",
|
||||||
|
"version": "v2.3.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/breeze.git",
|
||||||
|
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/breeze/zipball/1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
|
||||||
|
"reference": "1a29c5792818bd4cddf70b5f743a227e02fbcfcd",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/console": "^11.0|^12.0",
|
||||||
|
"illuminate/filesystem": "^11.0|^12.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0",
|
||||||
|
"illuminate/validation": "^11.0|^12.0",
|
||||||
|
"php": "^8.2.0",
|
||||||
|
"symfony/console": "^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/framework": "^11.0|^12.0",
|
||||||
|
"orchestra/testbench-core": "^9.0|^10.0",
|
||||||
|
"phpstan/phpstan": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Breeze\\BreezeServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Breeze\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Minimal Laravel authentication scaffolding with Blade and Tailwind.",
|
||||||
|
"keywords": [
|
||||||
|
"auth",
|
||||||
|
"laravel"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/breeze/issues",
|
||||||
|
"source": "https://github.com/laravel/breeze"
|
||||||
|
},
|
||||||
|
"time": "2025-07-18T18:49:59+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/pail",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.4",
|
"version": "v1.2.4",
|
||||||
|
|||||||
@@ -44,19 +44,109 @@ return [
|
|||||||
// Optional stub keys for local testing without external HTTP calls.
|
// Optional stub keys for local testing without external HTTP calls.
|
||||||
'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_TEST_KEYS', ''))))),
|
'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_TEST_KEYS', ''))))),
|
||||||
],
|
],
|
||||||
|
'paypal' => [
|
||||||
|
'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||||
|
'timeout' => (int) env('DEWEMOJI_PAYPAL_TIMEOUT', 10),
|
||||||
|
'webhook_ids' => [
|
||||||
|
'sandbox' => env('DEWEMOJI_PAYPAL_SANDBOX_WEBHOOK_ID', ''),
|
||||||
|
'live' => env('DEWEMOJI_PAYPAL_LIVE_WEBHOOK_ID', ''),
|
||||||
|
],
|
||||||
|
'plan_ids' => [
|
||||||
|
'sandbox' => [
|
||||||
|
'personal_monthly' => env('DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_MONTHLY', ''),
|
||||||
|
'personal_annual' => env('DEWEMOJI_PAYPAL_SANDBOX_PLAN_PERSONAL_ANNUAL', ''),
|
||||||
|
],
|
||||||
|
'live' => [
|
||||||
|
'personal_monthly' => env('DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_MONTHLY', ''),
|
||||||
|
'personal_annual' => env('DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_ANNUAL', ''),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'sandbox' => [
|
||||||
|
'api_base' => env('DEWEMOJI_PAYPAL_SANDBOX_API_BASE', 'https://api-m.sandbox.paypal.com'),
|
||||||
|
'web_base' => env('DEWEMOJI_PAYPAL_SANDBOX_WEB_BASE', 'https://www.sandbox.paypal.com'),
|
||||||
|
'client_id' => env('DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID', ''),
|
||||||
|
'client_secret' => env('DEWEMOJI_PAYPAL_SANDBOX_CLIENT_SECRET', ''),
|
||||||
|
],
|
||||||
|
'live' => [
|
||||||
|
'api_base' => env('DEWEMOJI_PAYPAL_LIVE_API_BASE', 'https://api-m.paypal.com'),
|
||||||
|
'web_base' => env('DEWEMOJI_PAYPAL_LIVE_WEB_BASE', 'https://www.paypal.com'),
|
||||||
|
'client_id' => env('DEWEMOJI_PAYPAL_LIVE_CLIENT_ID', ''),
|
||||||
|
'client_secret' => env('DEWEMOJI_PAYPAL_LIVE_CLIENT_SECRET', ''),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pakasir' => [
|
||||||
|
'enabled' => filter_var(env('DEWEMOJI_PAKASIR_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||||
|
'api_base' => env('DEWEMOJI_PAKASIR_API_BASE', ''),
|
||||||
|
'api_key' => env('DEWEMOJI_PAKASIR_API_KEY', ''),
|
||||||
|
'project' => env('DEWEMOJI_PAKASIR_PROJECT', ''),
|
||||||
|
'timeout' => (int) env('DEWEMOJI_PAKASIR_TIMEOUT', 10),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'cors' => [
|
'cors' => [
|
||||||
'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_ALLOWED_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))),
|
'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_ALLOWED_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))),
|
||||||
'allow_methods' => 'GET, POST, OPTIONS',
|
'allow_methods' => 'GET, POST, OPTIONS',
|
||||||
'allow_headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend',
|
'allow_headers' => 'Content-Type, Authorization, X-License-Key, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
|
||||||
|
],
|
||||||
|
|
||||||
|
'admin' => [
|
||||||
|
'token' => env('DEWEMOJI_ADMIN_TOKEN', ''),
|
||||||
|
],
|
||||||
|
|
||||||
|
'pricing' => [
|
||||||
|
'usd_rate' => (int) env('DEWEMOJI_USD_RATE', 15000),
|
||||||
|
'defaults' => [
|
||||||
|
[
|
||||||
|
'code' => 'personal_monthly',
|
||||||
|
'name' => 'Personal Monthly',
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => 30000,
|
||||||
|
'period' => 'month',
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'personal_annual',
|
||||||
|
'name' => 'Personal Annual',
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => 300000,
|
||||||
|
'period' => 'year',
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'code' => 'personal_lifetime',
|
||||||
|
'name' => 'Personal Lifetime',
|
||||||
|
'currency' => 'IDR',
|
||||||
|
'amount' => 900000,
|
||||||
|
'period' => null,
|
||||||
|
'status' => 'active',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'payments' => [
|
||||||
|
'qris_url' => env('DEWEMOJI_QRIS_URL', ''),
|
||||||
|
'paypal_url' => env('DEWEMOJI_PAYPAL_URL', ''),
|
||||||
],
|
],
|
||||||
|
|
||||||
'frontend' => [
|
'frontend' => [
|
||||||
'header_token' => env('DEWEMOJI_FRONTEND_HEADER', 'web-v1'),
|
'header_token' => env('DEWEMOJI_FRONTEND_HEADER', 'web-v1'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'public_access' => [
|
||||||
|
'enforce_whitelist' => filter_var(env('DEWEMOJI_PUBLIC_ENFORCE', true), FILTER_VALIDATE_BOOL),
|
||||||
|
'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_PUBLIC_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))),
|
||||||
|
'extension_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_EXTENSION_IDS', ''))))),
|
||||||
|
'hourly_limit' => (int) env('DEWEMOJI_PUBLIC_HOURLY_LIMIT', 5000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'extension_verification' => [
|
||||||
|
'enabled' => filter_var(env('DEWEMOJI_EXTENSION_VERIFY_ENABLED', true), FILTER_VALIDATE_BOOL),
|
||||||
|
'project_id' => env('DEWEMOJI_GOOGLE_PROJECT_ID', ''),
|
||||||
|
'server_key' => env('DEWEMOJI_GOOGLE_SERVER_KEY', ''),
|
||||||
|
'cache_ttl' => (int) env('DEWEMOJI_EXTENSION_VERIFY_CACHE_TTL', 3600),
|
||||||
|
],
|
||||||
|
|
||||||
'metrics' => [
|
'metrics' => [
|
||||||
'enabled' => filter_var(env('DEWEMOJI_METRICS_ENABLED', true), FILTER_VALIDATE_BOOL),
|
'enabled' => filter_var(env('DEWEMOJI_METRICS_ENABLED', true), FILTER_VALIDATE_BOOL),
|
||||||
'token' => (string) env('DEWEMOJI_METRICS_TOKEN', ''),
|
'token' => (string) env('DEWEMOJI_METRICS_TOKEN', ''),
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ return [
|
|||||||
// ],
|
// ],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'mailketing' => [
|
||||||
|
'transport' => 'mailketing',
|
||||||
|
'endpoint' => env('MAILKETING_API_URL', 'https://api.mailketing.co.id/api/v1/send'),
|
||||||
|
'token' => env('MAILKETING_API_TOKEN'),
|
||||||
|
'timeout' => env('MAILKETING_TIMEOUT', 10),
|
||||||
|
],
|
||||||
|
|
||||||
'resend' => [
|
'resend' => [
|
||||||
'transport' => 'resend',
|
'transport' => 'resend',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('tier', 20)->default('free')->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('tier');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_api_keys', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('key_hash', 64)->unique();
|
||||||
|
$table->string('key_prefix', 12)->index();
|
||||||
|
$table->string('name', 100)->nullable();
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('revoked_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_api_keys');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_keywords', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('emoji_slug');
|
||||||
|
$table->string('keyword', 200);
|
||||||
|
$table->string('lang', 10)->default('und');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'emoji_slug', 'keyword']);
|
||||||
|
$table->index(['user_id', 'emoji_slug']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_keywords');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('subscriptions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('plan', 20);
|
||||||
|
$table->string('status', 20)->default('active');
|
||||||
|
$table->string('provider', 20)->nullable();
|
||||||
|
$table->string('provider_ref', 100)->nullable();
|
||||||
|
$table->timestamp('started_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('canceled_at')->nullable();
|
||||||
|
$table->timestamp('next_renewal_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('subscriptions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pricing_plans', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code', 30)->unique();
|
||||||
|
$table->string('name', 50);
|
||||||
|
$table->string('currency', 10)->default('IDR');
|
||||||
|
$table->unsignedBigInteger('amount');
|
||||||
|
$table->string('period', 20)->nullable();
|
||||||
|
$table->string('status', 20)->default('active');
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pricing_plans');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('pricing_changes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('admin_ref', 120)->nullable();
|
||||||
|
$table->json('before')->nullable();
|
||||||
|
$table->json('after')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pricing_changes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('key', 120)->unique();
|
||||||
|
$table->json('value')->nullable();
|
||||||
|
$table->string('updated_by', 120)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhook_events', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('provider', 50);
|
||||||
|
$table->string('event_type', 120)->nullable();
|
||||||
|
$table->string('status', 50)->default('received');
|
||||||
|
$table->json('payload')->nullable();
|
||||||
|
$table->text('error')->nullable();
|
||||||
|
$table->timestamp('received_at')->nullable();
|
||||||
|
$table->timestamp('processed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('webhook_events', function (Blueprint $table): void {
|
||||||
|
$table->string('event_id', 120)->nullable()->after('provider');
|
||||||
|
$table->json('headers')->nullable()->after('payload');
|
||||||
|
$table->index(['provider', 'event_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('webhook_events', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['provider', 'event_id']);
|
||||||
|
$table->dropColumn('event_id');
|
||||||
|
$table->dropColumn('headers');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('role', 32)->default('user')->after('password');
|
||||||
|
$table->index('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['role']);
|
||||||
|
$table->dropColumn('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('admin_audit_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('admin_id')->nullable()->index();
|
||||||
|
$table->string('admin_email', 255)->nullable()->index();
|
||||||
|
$table->string('action', 64)->index();
|
||||||
|
$table->json('payload')->nullable();
|
||||||
|
$table->string('ip_address', 64)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('admin_audit_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('orders', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('plan_code', 40);
|
||||||
|
$table->string('type', 20); // one_time | subscription
|
||||||
|
$table->string('currency', 10);
|
||||||
|
$table->unsignedInteger('amount');
|
||||||
|
$table->string('status', 20)->default('pending');
|
||||||
|
$table->string('provider', 20)->nullable();
|
||||||
|
$table->string('provider_ref', 100)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
$table->index(['provider', 'provider_ref']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('payments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('provider', 20);
|
||||||
|
$table->string('type', 20); // one_time | subscription
|
||||||
|
$table->string('plan_code', 40);
|
||||||
|
$table->string('currency', 10);
|
||||||
|
$table->unsignedInteger('amount');
|
||||||
|
$table->string('status', 20)->default('pending');
|
||||||
|
$table->string('provider_ref', 100)->nullable();
|
||||||
|
$table->json('raw_payload')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
$table->index(['provider', 'provider_ref']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('payments');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,11 +15,6 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call(PricingPlanSeeder::class);
|
||||||
|
|
||||||
User::factory()->create([
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/database/seeders/PricingPlanSeeder.php
Normal file
27
app/database/seeders/PricingPlanSeeder.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\PricingPlan;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class PricingPlanSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$defaults = config('dewemoji.pricing.defaults', []);
|
||||||
|
foreach ($defaults as $plan) {
|
||||||
|
PricingPlan::updateOrCreate(
|
||||||
|
['code' => $plan['code']],
|
||||||
|
[
|
||||||
|
'name' => $plan['name'],
|
||||||
|
'currency' => $plan['currency'],
|
||||||
|
'amount' => $plan['amount'],
|
||||||
|
'period' => $plan['period'],
|
||||||
|
'status' => $plan['status'],
|
||||||
|
'meta' => $plan['meta'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
app/dewemoji-billing-integration-plan.md
Normal file
195
app/dewemoji-billing-integration-plan.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Dewemoji Billing Integration Plan (QRIS + PayPal)
|
||||||
|
|
||||||
|
This document outlines a proper, production-grade billing flow for Dewemoji using **QRIS (Pakasir)** and **PayPal Subscriptions**, including webhooks, retries, and license activation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Goals
|
||||||
|
|
||||||
|
- Replace primitive payment links with real provider integrations.
|
||||||
|
- Support **subscription** billing (monthly/annual) and **one-time lifetime**.
|
||||||
|
- Activate or revoke licenses based on webhook-confirmed payments.
|
||||||
|
- Log all webhook events and payment activity for audit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Data Model
|
||||||
|
|
||||||
|
### `orders` (new)
|
||||||
|
|
||||||
|
Acts as the primary record of what the user is buying. Payments link back to orders.
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id`
|
||||||
|
- `plan_code`
|
||||||
|
- `type` (`one_time`, `subscription`)
|
||||||
|
- `currency` (`IDR`, `USD`)
|
||||||
|
- `amount`
|
||||||
|
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
|
||||||
|
- `provider` (`qris`, `paypal`)
|
||||||
|
- `provider_ref`
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### `payments` (new)
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `user_id`
|
||||||
|
- `order_id`
|
||||||
|
- `provider` (`qris`, `paypal`)
|
||||||
|
- `type` (`one_time`, `subscription`)
|
||||||
|
- `plan_code` (`personal_monthly`, `personal_annual`, `personal_lifetime`)
|
||||||
|
- `currency` (`IDR`, `USD`)
|
||||||
|
- `amount`
|
||||||
|
- `status` (`pending`, `paid`, `failed`, `expired`, `refunded`)
|
||||||
|
- `provider_ref` (invoice_id / order_id / subscription_id)
|
||||||
|
- `raw_payload` (json)
|
||||||
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### `subscriptions` (existing)
|
||||||
|
Extend with:
|
||||||
|
- `provider`
|
||||||
|
- `provider_ref`
|
||||||
|
- `status` (`active`, `pending`, `canceled`, `expired`)
|
||||||
|
- `started_at`, `expires_at`, `canceled_at`
|
||||||
|
- `next_renewal_at` (optional)
|
||||||
|
|
||||||
|
### `webhook_events` (existing)
|
||||||
|
Continue to log inbound payloads and processing status:
|
||||||
|
- `provider`, `event_type`, `status`, `payload`, `received_at`, `processed_at`, `error_message`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Payment Flow (User Journey)
|
||||||
|
|
||||||
|
### Pricing Page (Frontend)
|
||||||
|
|
||||||
|
Each plan shows:
|
||||||
|
- **Primary currency** (based on geo + user toggle)
|
||||||
|
- **Two payment buttons** (real provider flow):
|
||||||
|
- **QRIS (IDR)** → subscription or one-time
|
||||||
|
- **PayPal (USD)** → subscription or one-time
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
#### QRIS (Pakasir)
|
||||||
|
- `POST /billing/qris/create`
|
||||||
|
- Creates invoice via Pakasir API
|
||||||
|
- Stores `payments` with `pending`
|
||||||
|
- Returns QR payment URL or QR code data
|
||||||
|
- `GET /billing/qris/return` (optional)
|
||||||
|
- Shows “pending / processing” state
|
||||||
|
|
||||||
|
#### PayPal Subscriptions
|
||||||
|
- `POST /billing/paypal/create`
|
||||||
|
- Creates PayPal subscription
|
||||||
|
- Stores `payments` with `pending`
|
||||||
|
- Returns approval URL
|
||||||
|
- `GET /billing/paypal/return`
|
||||||
|
- Shows “pending / processing” state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Webhook Processing (Critical)
|
||||||
|
|
||||||
|
Webhook endpoint:
|
||||||
|
```
|
||||||
|
POST /webhooks/{provider}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store inbound payloads in `webhook_events`, then process async (queue).
|
||||||
|
|
||||||
|
### PayPal Events
|
||||||
|
- `BILLING.SUBSCRIPTION.ACTIVATED` → mark subscription active, set `users.tier = personal`
|
||||||
|
- `BILLING.SUBSCRIPTION.CANCELLED` → mark subscription canceled
|
||||||
|
- `PAYMENT.SALE.COMPLETED` → mark payment paid
|
||||||
|
- `PAYMENT.SALE.DENIED` → mark payment failed
|
||||||
|
|
||||||
|
### Pakasir / QRIS Events
|
||||||
|
- `payment.paid` → mark payment paid, grant access
|
||||||
|
- `payment.expired` → mark payment failed/expired
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) License Activation Rules
|
||||||
|
|
||||||
|
When a payment or subscription is confirmed:
|
||||||
|
- Create or update a `subscriptions` record
|
||||||
|
- Set `users.tier = personal`
|
||||||
|
- Store provider refs (`provider_ref`)
|
||||||
|
- Log admin audit record
|
||||||
|
|
||||||
|
When revoked/expired:
|
||||||
|
- Update `subscriptions.status`
|
||||||
|
- Downgrade user if no active subscription remains
|
||||||
|
|
||||||
|
### Renewal Logic (QRIS manual renew)
|
||||||
|
|
||||||
|
- **If still active:** extend from current `expires_at`
|
||||||
|
- `expires_at = expires_at + duration`
|
||||||
|
- **If expired:** start from now
|
||||||
|
- `expires_at = now + duration`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Admin Dashboard Enhancements
|
||||||
|
|
||||||
|
Add or extend:
|
||||||
|
- **Payments list** (new screen)
|
||||||
|
- filter by provider/status/currency
|
||||||
|
- show raw provider ref
|
||||||
|
- **Subscriptions list** (already exists)
|
||||||
|
- show provider + status
|
||||||
|
- **Webhook events** (already exists)
|
||||||
|
- replay capability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Security & Reliability
|
||||||
|
|
||||||
|
- Validate webhook signatures (PayPal + Pakasir)
|
||||||
|
- Reject duplicate events (idempotency)
|
||||||
|
- Use queues for webhook processing
|
||||||
|
- Log all webhook failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Required Inputs (From Owner)
|
||||||
|
|
||||||
|
Before implementation:
|
||||||
|
|
||||||
|
1. **Pakasir API docs** (create invoice, webhook payload format)
|
||||||
|
2. **PayPal API credentials** (client_id, secret, webhook signing key)
|
||||||
|
3. Confirm **plans & pricing**:
|
||||||
|
- Monthly
|
||||||
|
- Annual
|
||||||
|
- Lifetime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Implementation Phases
|
||||||
|
|
||||||
|
**Phase 1 — Schema + Core Models**
|
||||||
|
- Add `orders` table
|
||||||
|
- Add `payments` table (link to orders)
|
||||||
|
- Extend `subscriptions`
|
||||||
|
- Update webhook model if needed
|
||||||
|
|
||||||
|
**Phase 2 — Provider APIs**
|
||||||
|
- Pakasir invoice create
|
||||||
|
- PayPal subscription create
|
||||||
|
|
||||||
|
**Phase 3 — Webhooks**
|
||||||
|
- Save raw events
|
||||||
|
- Process via queue + idempotency
|
||||||
|
|
||||||
|
**Phase 4 — UI**
|
||||||
|
- Pricing page buttons → real flows
|
||||||
|
- Admin payment + subscription tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Notes
|
||||||
|
|
||||||
|
- This plan assumes **proper subscription lifecycle** with webhooks.
|
||||||
|
- PayPal.me / static links are **not sufficient** for subscriptions.
|
||||||
|
- All access control must be tied to **confirmed payment status**.
|
||||||
143
app/dewemoji-user-dashboard-implementation-plan.md
Normal file
143
app/dewemoji-user-dashboard-implementation-plan.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Dewemoji User Dashboard Implementation Plan
|
||||||
|
|
||||||
|
**Source:** `dewemoji-ux-flow-brief.md`
|
||||||
|
**Goal:** Build the Personal user dashboard + inline personalization flows aligned with the UX brief.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Scope & Principles
|
||||||
|
|
||||||
|
- **Primary flow**: Add keywords directly on emoji detail pages (fast, contextual).
|
||||||
|
- **Secondary flow**: Manage keywords in the dashboard (bulk + power tools).
|
||||||
|
- **Zero friction** for Visitors/Free users; gentle upgrade prompts.
|
||||||
|
- **Shared layout** with admin (same shell, role-based sidebar).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) User States & Routing
|
||||||
|
|
||||||
|
### Visitor (non-logged)
|
||||||
|
- Public search + emoji detail only.
|
||||||
|
- CTA: “Sign up free” / “Upgrade to Personal”.
|
||||||
|
|
||||||
|
### Free user (logged, no subscription)
|
||||||
|
- See public content, “Your keywords” section locked.
|
||||||
|
- Upgrade nudges on detail + empty states in dashboard.
|
||||||
|
|
||||||
|
### Personal user (paid)
|
||||||
|
- Full access: quick add on detail + dashboard CRUD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) UI Screens (User Dashboard)
|
||||||
|
|
||||||
|
### 3.1 Dashboard shell (shared)
|
||||||
|
- Same layout as admin (sidebar, top bar).
|
||||||
|
- Role-based sidebar menu:
|
||||||
|
- Overview
|
||||||
|
- My Keywords
|
||||||
|
- API Keys
|
||||||
|
- Billing
|
||||||
|
- Preferences
|
||||||
|
- Support / Logout
|
||||||
|
|
||||||
|
### 3.2 Overview (user)
|
||||||
|
- Show:
|
||||||
|
- Total keywords
|
||||||
|
- Recent keyword additions (last 7 days)
|
||||||
|
- Synced devices (optional later)
|
||||||
|
- Small quick link to “My Keywords”.
|
||||||
|
|
||||||
|
### 3.3 My Keywords (primary management)
|
||||||
|
- Table: Emoji | Your keywords | Language | Actions
|
||||||
|
- Toolbar:
|
||||||
|
- + Add Keyword
|
||||||
|
- Import JSON
|
||||||
|
- Export JSON
|
||||||
|
- Search/filter
|
||||||
|
- Modal: emoji picker → add keywords + language.
|
||||||
|
|
||||||
|
### 3.4 API Keys
|
||||||
|
- List user API keys
|
||||||
|
- Create/revoke key
|
||||||
|
|
||||||
|
### 3.5 Billing
|
||||||
|
- Current plan + renewal / expiry
|
||||||
|
- Payment method (future)
|
||||||
|
- Upgrade CTA if free
|
||||||
|
|
||||||
|
### 3.6 Preferences
|
||||||
|
- Theme
|
||||||
|
- Tone lock / preferred skin tone (future)
|
||||||
|
- Locale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Site Page Enhancements (Non-dashboard)
|
||||||
|
|
||||||
|
### 4.1 Emoji Detail Page (critical)
|
||||||
|
- Show public keywords for everyone.
|
||||||
|
- If Personal: show “Your Keywords” list + quick add modal.
|
||||||
|
- If Free: show locked section + upgrade CTA.
|
||||||
|
- If Visitor: CTA to sign up.
|
||||||
|
|
||||||
|
### 4.2 Search Results Page
|
||||||
|
- Personal user: blend public + user keywords.
|
||||||
|
- Show “Your keyword” badge and “quick edit” button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) API Endpoints (v1)
|
||||||
|
|
||||||
|
### Keyword CRUD
|
||||||
|
- `GET /v1/emoji/{slug}?include_user_keywords=true`
|
||||||
|
- `POST /v1/keywords` (add keyword)
|
||||||
|
- `PUT /v1/keywords/{id}` (edit)
|
||||||
|
- `DELETE /v1/keywords/{id}`
|
||||||
|
- `GET /v1/keywords` (list user keywords)
|
||||||
|
|
||||||
|
### User keyword import/export
|
||||||
|
- `POST /v1/keywords/import`
|
||||||
|
- `GET /v1/keywords/export`
|
||||||
|
|
||||||
|
### Dashboard data
|
||||||
|
- `GET /v1/user/summary` (counts + recents)
|
||||||
|
- `GET /v1/user/apikeys` / `POST /v1/user/apikeys`
|
||||||
|
- `GET /v1/user/billing` (subscription status)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Database / Models
|
||||||
|
|
||||||
|
Existing:
|
||||||
|
- `user_keywords`
|
||||||
|
- `subscriptions`
|
||||||
|
|
||||||
|
Add if needed:
|
||||||
|
- `user_keyword_imports` (optional audit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Implementation Phases
|
||||||
|
|
||||||
|
### Phase A — Foundation
|
||||||
|
- Add user routes + dashboard views
|
||||||
|
- Layout reuse with role‑based sidebar
|
||||||
|
|
||||||
|
### Phase B — Keywords UX
|
||||||
|
- Detail page quick add
|
||||||
|
- Dashboard keyword CRUD + import/export
|
||||||
|
|
||||||
|
### Phase C — Billing & API Keys
|
||||||
|
- Billing summary + upgrade CTA
|
||||||
|
- API key list + create/revoke
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Acceptance Criteria
|
||||||
|
|
||||||
|
- Personal user can add keywords from detail page in <5 seconds.
|
||||||
|
- Keyword appears in search results immediately.
|
||||||
|
- Dashboard keyword table supports filter + edit + delete.
|
||||||
|
- Free users see upgrade prompts, not broken UI.
|
||||||
|
|
||||||
3581
app/package-lock.json
generated
Normal file
3581
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,19 @@
|
|||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.2",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"alpinejs": "^3.4.2",
|
||||||
|
"autoprefixer": "^10.4.2",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.1.0",
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"apexcharts": "^5.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/postcss.config.js
Normal file
6
app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
app/public/assets/fonts/PlusJakartaSans-Bold.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-Bold.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-BoldItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraBold.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraBoldItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraLight.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraLightItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-Italic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-Italic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-Light.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-Light.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-LightItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-LightItalic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-Medium.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-Medium.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-MediumItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-Regular.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-Regular.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-SemiBold.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
app/public/assets/fonts/PlusJakartaSans-SemiBoldItalic.ttf
Normal file
BIN
app/public/assets/fonts/PlusJakartaSans-SemiBoldItalic.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin invite</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
Admin access
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">🧭</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
Admin invite
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||||
|
You have been granted admin access for Dewemoji. Use the link below to enter the admin dashboard.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||||
|
Role: {{ role_name }}<br />
|
||||||
|
Granted by: {{ granted_by }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ admin_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
Open Admin Dashboard
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
If you do not expect this invite, please ignore the message.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Admins can manage users, subscriptions, and pricing.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
app/references/email-branded-designs/api-key-created.html
Normal file
102
app/references/email-branded-designs/api-key-created.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>API key created</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
API key
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">🔐</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
API key created
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||||
|
A new API key was created for your Dewemoji account.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||||
|
Label: {{ key_label }}<br />
|
||||||
|
Created: {{ created_at }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
Manage API Keys
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
If you did not create this key, revoke it immediately.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Never share your API key publicly. Treat it like a password.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
app/references/email-branded-designs/api-key-rotated.html
Normal file
102
app/references/email-branded-designs/api-key-rotated.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>API key rotated</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
API key
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">🔁</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
API key rotated
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||||
|
Your API key was rotated. Update any integrations using the old key.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||||
|
Label: {{ key_label }}<br />
|
||||||
|
Rotated: {{ rotated_at }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
View API Keys
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
If you did not rotate this key, revoke it immediately.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Keep your API key secure and do not commit it to public repos.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
98
app/references/email-branded-designs/email-verification.html
Normal file
98
app/references/email-branded-designs/email-verification.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dewemoji Email Verification</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
Email verification
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">✨</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
Verify your email
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:20px;">
|
||||||
|
Thanks for joining Dewemoji. Confirm your email to activate your account and start saving emoji keywords.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ verification_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
If the button does not work, paste this link into your browser:
|
||||||
|
<br />
|
||||||
|
<span style="color:#2563eb;">{{ verification_url }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
This link expires in 60 minutes. If you did not request this email, you can safely ignore it.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
app/references/email-branded-designs/license-key-issued.html
Normal file
103
app/references/email-branded-designs/license-key-issued.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>License key issued</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
License
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">🔑</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
License key issued
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||||
|
Your license key is ready. Use it to unlock Dewemoji in the extension and API.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||||
|
License: {{ license_key }}<br />
|
||||||
|
Tier: {{ tier }}<br />
|
||||||
|
Devices: {{ max_devices }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
Manage License
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Keep your license secure. You can rotate it anytime from your dashboard.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Need help getting started? Visit your API docs from the dashboard.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
app/references/email-branded-designs/license-key-updated.html
Normal file
103
app/references/email-branded-designs/license-key-updated.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>License key updated</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:26px 28px 10px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||||
|
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||||
|
Dewemoji
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||||
|
License
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 28px 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:14px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||||
|
<span style="display:inline-block; font-size:18px; line-height:34px;">🧩</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||||
|
License updated
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||||
|
Your Dewemoji license details have been updated.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||||
|
License: {{ license_key }}<br />
|
||||||
|
Tier: {{ tier }}<br />
|
||||||
|
Devices: {{ max_devices }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="padding-bottom:18px;">
|
||||||
|
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||||
|
View License
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
If you did not request this change, please contact support.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 28px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||||
|
Keep your license secure and avoid sharing it publicly.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||||
|
Dewemoji • Emoji discovery and keywords for creators
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user