Compare commits
64 Commits
9ce31c4dd8
...
master
Author | SHA1 | Date | |
---|---|---|---|
46ebd92dc1
|
|||
7f8521bb40
|
|||
f01226d91a
|
|||
6cb6dee6be
|
|||
0e9caf67ff
|
|||
ca70bb5487
|
|||
59ed135f20
|
|||
6077f91529
|
|||
5c485bb1c3
|
|||
27d979d77b
|
|||
15687a0c32
|
|||
37ea882ef7
|
|||
e624c2bb3e
|
|||
9631cd3edd
|
|||
f4a659fce5
|
|||
1ded811b36
|
|||
32977d9580
|
|||
aaf29e7228
|
|||
658ef3bddf
|
|||
fc0bc936ce
|
|||
3850ae6a8e
|
|||
21c99567b4
|
|||
1315c7f4d4
|
|||
630a532d98
|
|||
b9bb180113
|
|||
04d74d0d70
|
|||
6a8a0ed491
|
|||
0f835845bf
|
|||
c5d8a8d07f
|
|||
95e2ba1136
|
|||
1176fde8b4
|
|||
e634968e00
|
|||
282a1dbddc
|
|||
c64adace24
|
|||
8ac0b28c66
|
|||
8f71d7f9e5
|
|||
c435e63917
|
|||
243159e4cc
|
|||
42dad7095a
|
|||
d1efcdede8
|
|||
47680475b3
|
|||
6632d43f32
|
|||
29c4dcd71c
|
|||
e7aa887715
|
|||
0f05633996
|
|||
966af08a33
|
|||
b25b90a074
|
|||
dcbefeaaab
|
|||
eb83a0392a
|
|||
85fefcf724
|
|||
d17c26a228
|
|||
2e5ef8ff94
|
|||
7a5f410e36
|
|||
0b4e8a9777
|
|||
30fd912281
|
|||
5bf58f0194
|
|||
8e3e3f09df
|
|||
fa24f14c05
|
|||
a93b633e84
|
|||
97a7b876db
|
|||
909fe173c2
|
|||
58a44e8af4
|
|||
1075177511
|
|||
78f8a9e638
|
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
613
API_WALLET_FUNDS.md
Normal file
613
API_WALLET_FUNDS.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# Wallet Funds API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Wallet Funds API provides red packet functionality for the DysonNetwork platform, allowing users to create and distribute funds among multiple recipients with expiration and claiming mechanisms.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require Bearer token authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer {jwt_token}
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
### Enums
|
||||
|
||||
#### FundSplitType
|
||||
```typescript
|
||||
enum FundSplitType {
|
||||
Even = 0, // Equal distribution
|
||||
Random = 1 // Lucky draw distribution
|
||||
}
|
||||
```
|
||||
|
||||
#### FundStatus
|
||||
```typescript
|
||||
enum FundStatus {
|
||||
Created = 0, // Fund created, waiting for claims
|
||||
PartiallyReceived = 1, // Some recipients claimed
|
||||
FullyReceived = 2, // All recipients claimed
|
||||
Expired = 3, // Fund expired, unclaimed amounts refunded
|
||||
Refunded = 4 // Legacy status
|
||||
}
|
||||
```
|
||||
|
||||
### Request/Response Models
|
||||
|
||||
#### CreateFundRequest
|
||||
```typescript
|
||||
interface CreateFundRequest {
|
||||
recipientAccountIds: string[]; // UUIDs of recipients
|
||||
currency: string; // e.g., "points", "golds"
|
||||
totalAmount: number; // Total amount to distribute
|
||||
splitType: FundSplitType; // Even or Random
|
||||
message?: string; // Optional message
|
||||
expirationHours?: number; // Optional: hours until expiration (default: 24)
|
||||
pinCode: string; // Required: 6-digit PIN code for security
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFund
|
||||
```typescript
|
||||
interface SnWalletFund {
|
||||
id: string; // UUID
|
||||
currency: string;
|
||||
totalAmount: number;
|
||||
splitType: FundSplitType;
|
||||
status: FundStatus;
|
||||
message?: string;
|
||||
creatorAccountId: string; // UUID
|
||||
creatorAccount: SnAccount; // Creator account details (includes profile)
|
||||
recipients: SnWalletFundRecipient[];
|
||||
expiredAt: string; // ISO 8601 timestamp
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFundRecipient
|
||||
```typescript
|
||||
interface SnWalletFundRecipient {
|
||||
id: string; // UUID
|
||||
fundId: string; // UUID
|
||||
recipientAccountId: string; // UUID
|
||||
recipientAccount: SnAccount; // Recipient account details (includes profile)
|
||||
amount: number; // Allocated amount
|
||||
isReceived: boolean;
|
||||
receivedAt?: string; // ISO 8601 timestamp (if claimed)
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletTransaction
|
||||
```typescript
|
||||
interface SnWalletTransaction {
|
||||
id: string; // UUID
|
||||
payerWalletId?: string; // UUID (null for system transfers)
|
||||
payeeWalletId?: string; // UUID (null for system transfers)
|
||||
currency: string;
|
||||
amount: number;
|
||||
remarks?: string;
|
||||
type: TransactionType;
|
||||
createdAt: string; // ISO 8601 timestamp
|
||||
updatedAt: string; // ISO 8601 timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
type: string; // Error type
|
||||
title: string; // Error title
|
||||
status: number; // HTTP status code
|
||||
detail: string; // Error details
|
||||
instance?: string; // Request instance
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Create Fund
|
||||
|
||||
Creates a new fund (red packet) for distribution among recipients.
|
||||
|
||||
**Endpoint:** `POST /api/wallets/funds`
|
||||
|
||||
**Request Body:** `CreateFundRequest`
|
||||
|
||||
**Response:** `SnWalletFund` (201 Created)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST "/api/wallets/funds" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipientAccountIds": [
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"550e8400-e29b-41d4-a716-446655440001",
|
||||
"550e8400-e29b-41d4-a716-446655440002"
|
||||
],
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": "Even",
|
||||
"message": "Happy New Year! 🎉",
|
||||
"expirationHours": 48,
|
||||
"pinCode": "123456"
|
||||
}'
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": 0,
|
||||
"status": 0,
|
||||
"message": "Happy New Year! 🎉",
|
||||
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"creatorAccount": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"username": "creator_user"
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"amount": 33.34,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440006",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"amount": 33.33,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440007",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"amount": 33.33,
|
||||
"isReceived": false,
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
],
|
||||
"expiredAt": "2025-10-05T22:00:00Z",
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Invalid parameters, insufficient funds, invalid recipients
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `403 Forbidden`: Invalid PIN code
|
||||
- `422 Unprocessable Entity`: Business logic violations
|
||||
|
||||
---
|
||||
|
||||
### 2. Get Funds
|
||||
|
||||
Retrieves funds that the authenticated user is involved in (as creator or recipient).
|
||||
|
||||
**Endpoint:** `GET /api/wallets/funds`
|
||||
|
||||
**Query Parameters:**
|
||||
- `offset` (number, optional): Pagination offset (default: 0)
|
||||
- `take` (number, optional): Number of items to return (default: 20, max: 100)
|
||||
- `status` (FundStatus, optional): Filter by fund status
|
||||
|
||||
**Response:** `SnWalletFund[]` (200 OK)
|
||||
|
||||
**Headers:**
|
||||
- `X-Total`: Total number of funds matching the criteria
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/funds?offset=0&take=10&status=0" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": 0,
|
||||
"status": 0,
|
||||
"message": "Happy New Year! 🎉",
|
||||
"creatorAccountId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"creatorAccount": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"username": "creator_user"
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440005",
|
||||
"fundId": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"recipientAccountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"amount": 33.34,
|
||||
"isReceived": false
|
||||
}
|
||||
],
|
||||
"expiredAt": "2025-10-05T22:00:00Z",
|
||||
"createdAt": "2025-10-03T22:00:00Z",
|
||||
"updatedAt": "2025-10-03T22:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Fund
|
||||
|
||||
Retrieves details of a specific fund.
|
||||
|
||||
**Endpoint:** `GET /api/wallets/funds/{id}`
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Fund UUID
|
||||
|
||||
**Response:** `SnWalletFund` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:** (Same as create fund response)
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `403 Forbidden`: User doesn't have permission to view this fund
|
||||
- `404 Not Found`: Fund not found
|
||||
|
||||
---
|
||||
|
||||
### 4. Receive Fund
|
||||
|
||||
Claims the authenticated user's portion of a fund.
|
||||
|
||||
**Endpoint:** `POST /api/wallets/funds/{id}/receive`
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (string): Fund UUID
|
||||
|
||||
**Response:** `SnWalletTransaction` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440008",
|
||||
"payerWalletId": null,
|
||||
"payeeWalletId": "550e8400-e29b-41d4-a716-446655440009",
|
||||
"currency": "points",
|
||||
"amount": 33.34,
|
||||
"remarks": "Received fund portion from 550e8400-e29b-41d4-a716-446655440004",
|
||||
"type": 1,
|
||||
"createdAt": "2025-10-03T22:05:00Z",
|
||||
"updatedAt": "2025-10-03T22:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Fund expired, already claimed, not a recipient
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `404 Not Found`: Fund not found
|
||||
|
||||
---
|
||||
|
||||
### 5. Get Wallet Overview
|
||||
|
||||
Retrieves a summarized overview of wallet transactions grouped by type for graphing/charting purposes.
|
||||
|
||||
**Endpoint:** `GET /api/wallets/overview`
|
||||
|
||||
**Query Parameters:**
|
||||
- `startDate` (string, optional): Start date in ISO 8601 format (e.g., "2025-01-01T00:00:00Z")
|
||||
- `endDate` (string, optional): End date in ISO 8601 format (e.g., "2025-12-31T23:59:59Z")
|
||||
|
||||
**Response:** `WalletOverview` (200 OK)
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X GET "/api/wallets/overview?startDate=2025-01-01T00:00:00Z&endDate=2025-12-31T23:59:59Z" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"accountId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"startDate": "2025-01-01T00:00:00.0000000Z",
|
||||
"endDate": "2025-12-31T23:59:59.0000000Z",
|
||||
"summary": {
|
||||
"System": {
|
||||
"type": "System",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 150.00,
|
||||
"spending": 0.00,
|
||||
"net": 150.00
|
||||
}
|
||||
}
|
||||
},
|
||||
"Transfer": {
|
||||
"type": "Transfer",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 25.00,
|
||||
"spending": 75.00,
|
||||
"net": -50.00
|
||||
},
|
||||
"golds": {
|
||||
"currency": "golds",
|
||||
"income": 0.00,
|
||||
"spending": 10.00,
|
||||
"net": -10.00
|
||||
}
|
||||
}
|
||||
},
|
||||
"Order": {
|
||||
"type": "Order",
|
||||
"currencies": {
|
||||
"points": {
|
||||
"currency": "points",
|
||||
"income": 0.00,
|
||||
"spending": 200.00,
|
||||
"net": -200.00
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"totalIncome": 175.00,
|
||||
"totalSpending": 285.00,
|
||||
"netTotal": -110.00
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
- `accountId`: User's account UUID
|
||||
- `startDate`/`endDate`: Date range applied (ISO 8601 format)
|
||||
- `summary`: Object keyed by transaction type
|
||||
- `type`: Transaction type name
|
||||
- `currencies`: Object keyed by currency code
|
||||
- `currency`: Currency name
|
||||
- `income`: Total money received
|
||||
- `spending`: Total money spent
|
||||
- `net`: Income minus spending
|
||||
- `totalIncome`: Sum of all income across all types/currencies
|
||||
- `totalSpending`: Sum of all spending across all types/currencies
|
||||
- `netTotal`: Overall net (totalIncome - totalSpending)
|
||||
|
||||
**Error Responses:**
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
|
||||
## Error Codes
|
||||
|
||||
### Common Error Types
|
||||
|
||||
#### Validation Errors
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "At least one recipient is required",
|
||||
"instance": "/api/wallets/funds"
|
||||
}
|
||||
```
|
||||
|
||||
#### Insufficient Funds
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Insufficient funds",
|
||||
"instance": "/api/wallets/funds"
|
||||
}
|
||||
```
|
||||
|
||||
#### Fund Not Available
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "Fund is no longer available",
|
||||
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||
}
|
||||
```
|
||||
|
||||
#### Already Claimed
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
|
||||
"title": "Bad Request",
|
||||
"status": 400,
|
||||
"detail": "You have already received this fund",
|
||||
"instance": "/api/wallets/funds/550e8400-e29b-41d4-a716-446655440003/receive"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- **Create Fund**: 10 requests per minute per user
|
||||
- **Get Funds**: 60 requests per minute per user
|
||||
- **Get Fund**: 60 requests per minute per user
|
||||
- **Receive Fund**: 30 requests per minute per user
|
||||
|
||||
## Webhooks/Notifications
|
||||
|
||||
The system integrates with the platform's notification system:
|
||||
|
||||
- **Fund Created**: Creator receives confirmation
|
||||
- **Fund Claimed**: Creator receives notification when someone claims
|
||||
- **Fund Expired**: Creator receives refund notification
|
||||
|
||||
## SDK Examples
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
// Create a fund
|
||||
const createFund = async (fundData: CreateFundRequest): Promise<SnWalletFund> => {
|
||||
const response = await fetch('/api/wallets/funds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(fundData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Get user's funds
|
||||
const getFunds = async (params?: {
|
||||
offset?: number;
|
||||
take?: number;
|
||||
status?: FundStatus;
|
||||
}): Promise<SnWalletFund[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.offset) queryParams.set('offset', params.offset.toString());
|
||||
if (params?.take) queryParams.set('take', params.take.toString());
|
||||
if (params?.status !== undefined) queryParams.set('status', params.status.toString());
|
||||
|
||||
const response = await fetch(`/api/wallets/funds?${queryParams}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
// Claim a fund
|
||||
const receiveFund = async (fundId: string): Promise<SnWalletTransaction> => {
|
||||
const response = await fetch(`/api/wallets/funds/${fundId}/receive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
class FundSplitType(Enum):
|
||||
EVEN = 0
|
||||
RANDOM = 1
|
||||
|
||||
class FundStatus(Enum):
|
||||
CREATED = 0
|
||||
PARTIALLY_RECEIVED = 1
|
||||
FULLY_RECEIVED = 2
|
||||
EXPIRED = 3
|
||||
REFUNDED = 4
|
||||
|
||||
def create_fund(token: str, fund_data: dict) -> dict:
|
||||
"""Create a new fund"""
|
||||
response = requests.post(
|
||||
'/api/wallets/funds',
|
||||
json=fund_data,
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_funds(
|
||||
token: str,
|
||||
offset: int = 0,
|
||||
take: int = 20,
|
||||
status: Optional[FundStatus] = None
|
||||
) -> List[dict]:
|
||||
"""Get user's funds"""
|
||||
params = {'offset': offset, 'take': take}
|
||||
if status is not None:
|
||||
params['status'] = status.value
|
||||
|
||||
response = requests.get(
|
||||
'/api/wallets/funds',
|
||||
params=params,
|
||||
headers={'Authorization': f'Bearer {token}'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def receive_fund(token: str, fund_id: str) -> dict:
|
||||
"""Claim a fund portion"""
|
||||
response = requests.post(
|
||||
f'/api/wallets/funds/{fund_id}/receive',
|
||||
headers={'Authorization': f'Bearer {token}'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0
|
||||
- Initial release with basic red packet functionality
|
||||
- Support for even and random split types
|
||||
- 24-hour expiration with automatic refunds
|
||||
- RESTful API endpoints
|
||||
- Comprehensive error handling
|
||||
|
||||
## Support
|
||||
|
||||
For API support or questions:
|
||||
- Check the main documentation at `README_WALLET_FUNDS.md`
|
||||
- Review error messages for specific guidance
|
||||
- Contact the development team for technical issues
|
@@ -4,41 +4,35 @@ var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var isDev = builder.Environment.IsDevelopment();
|
||||
|
||||
// Database was configured separately in each service.
|
||||
// var database = builder.AddPostgres("database");
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
var queue = builder.AddNats("queue").WithJetStream();
|
||||
|
||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring")
|
||||
.WithReference(queue);
|
||||
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
|
||||
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(ringService);
|
||||
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService);
|
||||
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
|
||||
.WithReference(cache)
|
||||
.WithReference(queue)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(driveService);
|
||||
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
|
||||
.WithReference(cache)
|
||||
.WithReference(passService)
|
||||
.WithReference(ringService)
|
||||
.WithReference(sphereService);
|
||||
|
||||
passService.WithReference(developService).WithReference(driveService);
|
||||
|
||||
List<IResourceBuilder<ProjectResource>> services =
|
||||
[ringService, passService, driveService, sphereService, developService];
|
||||
|
||||
for (var idx = 0; idx < services.Count; idx++)
|
||||
{
|
||||
var service = services[idx];
|
||||
|
||||
service.WithReference(cache).WithReference(queue);
|
||||
|
||||
var grpcPort = 7002 + idx;
|
||||
|
||||
if (isDev)
|
||||
@@ -61,14 +55,12 @@ for (var idx = 0; idx < services.Count; idx++)
|
||||
ringService.WithReference(passService);
|
||||
|
||||
var gateway = builder.AddProject<Projects.DysonNetwork_Gateway>("gateway")
|
||||
.WithReference(ringService)
|
||||
.WithReference(passService)
|
||||
.WithReference(driveService)
|
||||
.WithReference(sphereService)
|
||||
.WithReference(developService)
|
||||
.WithEnvironment("HTTP_PORTS", "5001")
|
||||
.WithHttpEndpoint(port: 5001, targetPort: null, isProxied: false, name: "http");
|
||||
|
||||
foreach (var service in services)
|
||||
gateway.WithReference(service);
|
||||
|
||||
builder.AddDockerComposeEnvironment("docker-compose");
|
||||
|
||||
builder.Build().Run();
|
||||
|
@@ -1,30 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.4.2"/>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.4.2"/>
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.1" />
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>a68b3195-a00d-40c2-b5ed-d675356b7cde</UserSecretsId>
|
||||
<RootNamespace>DysonNetwork.Control</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Gateway\DysonNetwork.Gateway.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
@@ -10,7 +10,9 @@
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21175",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189"
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189",
|
||||
"DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21260",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22052"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
@@ -22,8 +24,9 @@
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19163",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185"
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20185",
|
||||
"DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:22108"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -31,7 +31,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@@ -16,7 +16,7 @@ namespace DysonNetwork.Develop.Identity;
|
||||
[Authorize]
|
||||
public class BotAccountController(
|
||||
BotAccountService botService,
|
||||
DeveloperService developerService,
|
||||
DeveloperService ds,
|
||||
DevProjectService projectService,
|
||||
ILogger<BotAccountController> logger,
|
||||
AccountClientHelper accounts,
|
||||
@@ -50,9 +50,9 @@ public class BotAccountController(
|
||||
]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
[Required][MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
[Required][MaxLength(1024)] public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||
}
|
||||
@@ -68,7 +68,7 @@ public class BotAccountController(
|
||||
|
||||
[MaxLength(256)] public string? Nick { get; set; } = string.Empty;
|
||||
|
||||
[Required] [MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
|
||||
[Required][MaxLength(1024)] public string? Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)] public string? Language { get; set; }
|
||||
|
||||
@@ -83,11 +83,11 @@ public class BotAccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
Shared.Proto.PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to list bots");
|
||||
|
||||
@@ -108,11 +108,11 @@ public class BotAccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
Shared.Proto.PublisherMemberRole.Viewer))
|
||||
return StatusCode(403, "You must be an viewer of the developer to view bot details");
|
||||
|
||||
@@ -137,11 +137,11 @@ public class BotAccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
Shared.Proto.PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to create a bot");
|
||||
|
||||
@@ -206,11 +206,11 @@ public class BotAccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
Shared.Proto.PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to update a bot");
|
||||
|
||||
@@ -267,11 +267,11 @@ public class BotAccountController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer is null)
|
||||
return NotFound("Developer not found");
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id),
|
||||
Shared.Proto.PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You must be an editor of the developer to delete a bot");
|
||||
|
||||
@@ -374,7 +374,7 @@ public class BotAccountController(
|
||||
AccountId = bot.Id.ToString(),
|
||||
Label = request.Label
|
||||
};
|
||||
|
||||
|
||||
var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey);
|
||||
return Ok(SnApiKey.FromProtoValue(createdKey));
|
||||
}
|
||||
@@ -443,10 +443,10 @@ public class BotAccountController(
|
||||
Account currentUser,
|
||||
Shared.Proto.PublisherMemberRole requiredRole)
|
||||
{
|
||||
var developer = await developerService.GetDeveloperByName(pubName);
|
||||
var developer = await ds.GetDeveloperByName(pubName);
|
||||
if (developer == null) return (null, null, null);
|
||||
|
||||
if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
|
||||
if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole))
|
||||
return (null, null, null);
|
||||
|
||||
var project = await projectService.GetProjectAsync(projectId, developer.Id);
|
||||
@@ -457,4 +457,4 @@ public class BotAccountController(
|
||||
|
||||
return (developer, project, bot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,7 +20,7 @@ public class BotAccountService(
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
|
||||
public async Task<List<SnBotAccount>> GetBotsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.BotAccounts
|
||||
.Where(b => b.ProjectId == projectId)
|
||||
@@ -97,7 +97,7 @@ public class BotAccountService(
|
||||
{
|
||||
db.Update(bot);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// Update the bot account in the Pass service
|
||||
@@ -155,9 +155,8 @@ public class BotAccountService(
|
||||
public async Task<SnBotAccount?> LoadBotAccountAsync(SnBotAccount bot) =>
|
||||
(await LoadBotsAccountAsync([bot])).FirstOrDefault();
|
||||
|
||||
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(IEnumerable<SnBotAccount> bots)
|
||||
public async Task<List<SnBotAccount>> LoadBotsAccountAsync(List<SnBotAccount> bots)
|
||||
{
|
||||
bots = [.. bots];
|
||||
var automatedIds = bots.Select(b => b.Id).ToList();
|
||||
var data = await accounts.GetBotAccountBatch(automatedIds);
|
||||
|
||||
@@ -168,6 +167,6 @@ public class BotAccountService(
|
||||
.FirstOrDefault(e => e.AutomatedId == bot.Id);
|
||||
}
|
||||
|
||||
return bots as List<SnBotAccount> ?? [];
|
||||
return bots;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,12 +10,9 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Swagger": {
|
||||
"PublicBasePath": "/develop"
|
||||
},
|
||||
|
@@ -68,7 +68,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@@ -41,7 +41,7 @@ public class BroadcastEventHandler(
|
||||
|
||||
await js.EnsureStreamCreated("file_events", [FileUploadedEvent.Type]);
|
||||
var fileUploadedConsumer = await js.CreateOrUpdateConsumerAsync("file_events",
|
||||
new ConsumerConfig("drive_file_uploaded_handler"), cancellationToken: stoppingToken);
|
||||
new ConsumerConfig("drive_file_uploaded_handler") { MaxDeliver = 3 }, cancellationToken: stoppingToken);
|
||||
|
||||
var accountDeletedTask = HandleAccountDeleted(accountEventConsumer, stoppingToken);
|
||||
var fileUploadedTask = HandleFileUploaded(fileUploadedConsumer, stoppingToken);
|
||||
@@ -75,8 +75,8 @@ public class BroadcastEventHandler(
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload?.FileId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
logger.LogError(ex, "Error processing FileUploadedEvent for file {FileId}", payload.FileId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken, delay: TimeSpan.FromSeconds(60));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -70,13 +70,11 @@ public class FileController(
|
||||
}
|
||||
}
|
||||
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, "File is being processed. Please try again later.");
|
||||
return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
|
||||
}
|
||||
|
||||
if (!file.PoolId.HasValue)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
|
||||
}
|
||||
|
||||
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
||||
if (pool is null)
|
||||
|
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using DysonNetwork.Drive.Billing;
|
||||
using DysonNetwork.Drive.Storage.Model;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -31,15 +32,18 @@ public class FileUploadController(
|
||||
[HttpPost("create")]
|
||||
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
}
|
||||
|
||||
if (!currentUser.IsSuperuser)
|
||||
{
|
||||
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
||||
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
||||
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
||||
if (!allowed.HasPermission)
|
||||
{
|
||||
return Forbid();
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,23 +52,19 @@ public class FileUploadController(
|
||||
var pool = await fileService.GetPoolAsync(request.PoolId.Value);
|
||||
if (pool is null)
|
||||
{
|
||||
return BadRequest("Pool not found");
|
||||
return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
if (pool.PolicyConfig.RequirePrivilege > 0)
|
||||
if (pool.PolicyConfig.RequirePrivilege is > 0)
|
||||
{
|
||||
if (currentUser.PerkSubscription is null)
|
||||
{
|
||||
return new ObjectResult("You need to have join the Stellar Program to use this pool")
|
||||
{ StatusCode = 403 };
|
||||
}
|
||||
|
||||
var privilege =
|
||||
currentUser.PerkSubscription is null ? 0 :
|
||||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
||||
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
||||
{
|
||||
return new ObjectResult(
|
||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}")
|
||||
return new ObjectResult(ApiError.Unauthorized(
|
||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
|
||||
forbidden: true))
|
||||
{
|
||||
StatusCode = 403
|
||||
};
|
||||
@@ -74,14 +74,19 @@ public class FileUploadController(
|
||||
var policy = pool.PolicyConfig;
|
||||
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
|
||||
{
|
||||
return new ObjectResult("File encryption is not allowed in this pool") { StatusCode = 403 };
|
||||
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
|
||||
{ StatusCode = 403 };
|
||||
}
|
||||
|
||||
if (policy.AcceptTypes is { Count: > 0 })
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.ContentType))
|
||||
{
|
||||
return BadRequest("Content type is required by the pool's policy");
|
||||
return new ObjectResult(ApiError.Validation(new Dictionary<string, string[]>
|
||||
{
|
||||
{ "contentType", new[] { "Content type is required by the pool's policy" } }
|
||||
}))
|
||||
{ StatusCode = 400 };
|
||||
}
|
||||
|
||||
var foundMatch = policy.AcceptTypes.Any(acceptType =>
|
||||
@@ -97,15 +102,18 @@ public class FileUploadController(
|
||||
|
||||
if (!foundMatch)
|
||||
{
|
||||
return new ObjectResult($"Content type {request.ContentType} is not allowed by the pool's policy")
|
||||
{ StatusCode = 403 };
|
||||
return new ObjectResult(
|
||||
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
|
||||
true))
|
||||
{ StatusCode = 403 };
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
|
||||
{
|
||||
return new ObjectResult(
|
||||
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}")
|
||||
return new ObjectResult(ApiError.Unauthorized(
|
||||
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
|
||||
true))
|
||||
{
|
||||
StatusCode = 403
|
||||
};
|
||||
@@ -118,8 +126,10 @@ public class FileUploadController(
|
||||
);
|
||||
if (!ok)
|
||||
{
|
||||
return new ObjectResult($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB")
|
||||
{ StatusCode = 403 };
|
||||
return new ObjectResult(
|
||||
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
|
||||
true))
|
||||
{ StatusCode = 403 };
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_tempPath))
|
||||
@@ -170,7 +180,7 @@ public class FileUploadController(
|
||||
ChunksCount = chunksCount
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public class UploadChunkRequest
|
||||
{
|
||||
[Required]
|
||||
@@ -186,7 +196,7 @@ public class FileUploadController(
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
{
|
||||
return NotFound("Upload task not found.");
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
var chunkPath = Path.Combine(taskPath, $"{chunkIndex}.chunk");
|
||||
@@ -202,19 +212,20 @@ public class FileUploadController(
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
if (!Directory.Exists(taskPath))
|
||||
{
|
||||
return NotFound("Upload task not found.");
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
var taskJsonPath = Path.Combine(taskPath, "task.json");
|
||||
if (!System.IO.File.Exists(taskJsonPath))
|
||||
{
|
||||
return NotFound("Upload task metadata not found.");
|
||||
return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
|
||||
}
|
||||
|
||||
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
|
||||
if (task == null)
|
||||
{
|
||||
return BadRequest("Invalid task metadata.");
|
||||
return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
|
||||
{ StatusCode = 400 };
|
||||
}
|
||||
|
||||
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
||||
@@ -229,7 +240,9 @@ public class FileUploadController(
|
||||
mergedStream.Close();
|
||||
System.IO.File.Delete(mergedFilePath);
|
||||
Directory.Delete(taskPath, true);
|
||||
return BadRequest($"Chunk {i} is missing.");
|
||||
return new ObjectResult(new ApiError
|
||||
{ Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 })
|
||||
{ StatusCode = 400 };
|
||||
}
|
||||
|
||||
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
||||
@@ -237,21 +250,24 @@ public class FileUploadController(
|
||||
}
|
||||
}
|
||||
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
}
|
||||
|
||||
var fileId = await Nanoid.GenerateAsync();
|
||||
|
||||
var cloudFile = await fileService.ProcessNewFileAsync(
|
||||
currentUser,
|
||||
fileId,
|
||||
task.PoolId.ToString(),
|
||||
task.BundleId?.ToString(),
|
||||
mergedFilePath,
|
||||
task.FileName,
|
||||
task.ContentType,
|
||||
task.EncryptPassword,
|
||||
task.ExpiredAt
|
||||
);
|
||||
currentUser,
|
||||
fileId,
|
||||
task.PoolId.ToString(),
|
||||
task.BundleId?.ToString(),
|
||||
mergedFilePath,
|
||||
task.FileName,
|
||||
task.ContentType,
|
||||
task.EncryptPassword,
|
||||
task.ExpiredAt
|
||||
);
|
||||
|
||||
// Clean up
|
||||
Directory.Delete(taskPath, true);
|
||||
@@ -259,4 +275,4 @@ public class FileUploadController(
|
||||
|
||||
return Ok(cloudFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
[ApiController]
|
||||
[Route("config")]
|
||||
public class ConfigurationController(IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IActionResult Get() => Ok(configuration.GetSection("Client").Get<Dictionary<string, object>>());
|
||||
|
||||
[HttpGet("site")]
|
||||
public IActionResult GetSiteUrl() => Ok(configuration["SiteUrl"]);
|
||||
}
|
@@ -12,7 +12,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -17,19 +17,43 @@ builder.Services.AddCors(options =>
|
||||
policy.SetIsOriginAllowed(origin => true)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
.AllowCredentials()
|
||||
.WithExposedHeaders("X-Total");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.AddFixedWindowLimiter("fixed", limiterOptions =>
|
||||
options.AddPolicy("fixed", context =>
|
||||
{
|
||||
limiterOptions.PermitLimit = 120;
|
||||
limiterOptions.Window = TimeSpan.FromMinutes(1);
|
||||
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
limiterOptions.QueueLimit = 0;
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: ip,
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 120, // 120 requests...
|
||||
Window = TimeSpan.FromMinutes(1), // ...per minute per IP
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 10 // allow short bursts instead of instant 503s
|
||||
});
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, token) =>
|
||||
{
|
||||
// Log the rejected IP
|
||||
var logger = context.HttpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("RateLimiter");
|
||||
|
||||
var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
logger.LogWarning("Rate limit exceeded for IP: {IP}", ip);
|
||||
|
||||
// Respond to the client
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsync(
|
||||
"Rate limit exceeded. Try again later.", token);
|
||||
};
|
||||
});
|
||||
|
||||
var serviceNames = new[] { "ring", "pass", "drive", "sphere", "develop" };
|
||||
@@ -87,7 +111,7 @@ var swaggerRoutes = serviceNames.Select(serviceName => new RouteConfig
|
||||
RouteId = $"{serviceName}-swagger",
|
||||
ClusterId = serviceName,
|
||||
Match = new RouteMatch { Path = $"/swagger/{serviceName}/{{**catch-all}}" },
|
||||
Transforms =
|
||||
Transforms =
|
||||
[
|
||||
new Dictionary<string, string> { { "PathRemovePrefix", $"/swagger/{serviceName}" } },
|
||||
new Dictionary<string, string> { { "PathPrefix", "/swagger" } }
|
||||
@@ -99,6 +123,20 @@ var routes = specialRoutes.Concat(apiRoutes).Concat(swaggerRoutes).ToArray();
|
||||
var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
||||
{
|
||||
ClusterId = serviceName,
|
||||
HealthCheck = new()
|
||||
{
|
||||
Active = new()
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromSeconds(10),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Path = "/health"
|
||||
},
|
||||
Passive = new()
|
||||
{
|
||||
Enabled = true
|
||||
}
|
||||
},
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "destination1", new DestinationConfig { Address = $"http://{serviceName}" } }
|
||||
@@ -106,16 +144,28 @@ var clusters = serviceNames.Select(serviceName => new ClusterConfig
|
||||
}).ToArray();
|
||||
|
||||
builder.Services
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(routes, clusters)
|
||||
.AddServiceDiscoveryDestinationResolver();
|
||||
.AddReverseProxy()
|
||||
.LoadFromMemory(routes, clusters)
|
||||
.AddServiceDiscoveryDestinationResolver();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.All
|
||||
};
|
||||
forwardedHeadersOptions.KnownNetworks.Clear();
|
||||
forwardedHeadersOptions.KnownProxies.Clear();
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.MapReverseProxy().RequireRateLimiting("fixed");
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
@@ -5,5 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"SiteUrl": "http://localhost:3000",
|
||||
"Client": {
|
||||
"SomeSetting": "SomeValue"
|
||||
}
|
||||
}
|
@@ -2,8 +2,8 @@ using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Credit;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Error;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Error;
|
||||
using DysonNetwork.Shared.Http;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -80,6 +80,7 @@ public class AccountCurrentController(
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
public Shared.Models.UsernameColor? UsernameColor { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
public List<ProfileLink>? Links { get; set; }
|
||||
|
||||
@@ -115,6 +116,7 @@ public class AccountCurrentController(
|
||||
if (request.Location is not null) profile.Location = request.Location;
|
||||
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
||||
if (request.Links is not null) profile.Links = request.Links;
|
||||
if (request.UsernameColor is not null) profile.UsernameColor = request.UsernameColor;
|
||||
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
@@ -931,4 +933,4 @@ public class AccountCurrentController(
|
||||
.ToListAsync();
|
||||
return Ok(records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,8 +3,11 @@ using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Net;
|
||||
using NodaTime;
|
||||
using NodaTime.Extensions;
|
||||
|
||||
@@ -17,7 +20,8 @@ public class AccountEventService(
|
||||
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||
RingService.RingServiceClient pusher,
|
||||
SubscriptionService subscriptions,
|
||||
Pass.Leveling.ExperienceService experienceService
|
||||
Pass.Leveling.ExperienceService experienceService,
|
||||
INatsConnection nats
|
||||
)
|
||||
{
|
||||
private static readonly Random Random = new();
|
||||
@@ -37,6 +41,19 @@ public class AccountEventService(
|
||||
cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
private async Task BroadcastStatusUpdate(SnAccountStatus status)
|
||||
{
|
||||
await nats.PublishAsync(
|
||||
AccountStatusUpdatedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
|
||||
{
|
||||
AccountId = status.AccountId,
|
||||
Status = status,
|
||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}).ToByteArray()
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<SnAccountStatus> GetStatus(Guid userId)
|
||||
{
|
||||
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||
@@ -158,6 +175,8 @@ public class AccountEventService(
|
||||
db.AccountStatuses.Add(status);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await BroadcastStatusUpdate(status);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -167,6 +186,7 @@ public class AccountEventService(
|
||||
db.Update(status);
|
||||
await db.SaveChangesAsync();
|
||||
PurgeStatusCache(user.Id);
|
||||
await BroadcastStatusUpdate(status);
|
||||
}
|
||||
|
||||
private const int FortuneTipCount = 14; // This will be the max index for each type (positive/negative)
|
||||
|
@@ -160,6 +160,26 @@ public class AccountServiceGrpc(
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<GetAccountBatchResponse> SearchAccount(SearchAccountRequest request, ServerCallContext context)
|
||||
{
|
||||
var accounts = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Where(a => EF.Functions.ILike(a.Name, $"%{request.Query}%"))
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
var perks = await subscriptions.GetPerkSubscriptionsAsync(
|
||||
accounts.Select(x => x.Id).ToList()
|
||||
);
|
||||
foreach (var account in accounts)
|
||||
if (perks.TryGetValue(account.Id, out var perk))
|
||||
account.PerkSubscription = perk?.ToReference();
|
||||
|
||||
var response = new GetAccountBatchResponse();
|
||||
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
|
@@ -43,7 +43,10 @@ public class AppDatabase(
|
||||
public DbSet<SnWalletPocket> WalletPockets { get; set; } = null!;
|
||||
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
|
||||
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
|
||||
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
|
||||
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
|
||||
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
|
||||
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
|
||||
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
|
||||
|
||||
public DbSet<SnAccountPunishment> Punishments { get; set; } = null!;
|
||||
@@ -278,4 +281,4 @@ public static class OptionalQueryExtensions
|
||||
{
|
||||
return condition ? transform(source) : source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -351,7 +351,7 @@ public class OidcProviderController(
|
||||
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||
id_token_signing_alg_values_supported = new[] { "HS256", "RS256" },
|
||||
subject_types_supported = new[] { "public" },
|
||||
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||
code_challenge_methods_supported = new[] { "S256" },
|
||||
|
@@ -200,11 +200,13 @@ public class OidcProviderService(
|
||||
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
||||
}
|
||||
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id.ToString(),
|
||||
Audience = client.Slug.ToString(),
|
||||
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
|
||||
NotBefore = now.ToDateTimeUtc(),
|
||||
SigningCredentials = new SigningCredentials(
|
||||
@@ -314,6 +316,7 @@ public class OidcProviderService(
|
||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new Claim(JwtRegisteredClaimNames.Azp, client.Slug),
|
||||
]),
|
||||
Expires = expiresAt.ToDateTimeUtc(),
|
||||
Issuer = _options.IssuerUri,
|
||||
@@ -516,4 +519,4 @@ public class OidcProviderService(
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -16,7 +16,8 @@ public class ConnectionController(
|
||||
IEnumerable<OidcService> oidcServices,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
ICacheService cache
|
||||
ICacheService cache,
|
||||
IConfiguration configuration
|
||||
) : ControllerBase
|
||||
{
|
||||
private const string StateCachePrefix = "oidc-state:";
|
||||
@@ -128,7 +129,7 @@ public class ConnectionController(
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[Route("/auth/callback/{provider}")]
|
||||
[Route("/api/auth/callback/{provider}")]
|
||||
[HttpGet, HttpPost]
|
||||
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
||||
{
|
||||
@@ -142,10 +143,10 @@ public class ConnectionController(
|
||||
|
||||
// Get the state from the cache
|
||||
var stateKey = $"{StateCachePrefix}{callbackData.State}";
|
||||
|
||||
|
||||
// Try to get the state as OidcState first (new format)
|
||||
var oidcState = await cache.GetAsync<OidcState>(stateKey);
|
||||
|
||||
|
||||
// If not found, try to get as string (legacy format)
|
||||
if (oidcState == null)
|
||||
{
|
||||
@@ -153,7 +154,7 @@ public class ConnectionController(
|
||||
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
|
||||
return BadRequest("Invalid or expired state parameter");
|
||||
}
|
||||
|
||||
|
||||
// Remove the state from cache to prevent replay attacks
|
||||
await cache.RemoveAsync(stateKey);
|
||||
|
||||
@@ -277,7 +278,9 @@ public class ConnectionController(
|
||||
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
|
||||
await cache.RemoveAsync(returnUrlKey);
|
||||
|
||||
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
|
||||
var siteUrl = configuration["SiteUrl"];
|
||||
|
||||
return Redirect(string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleLoginOrRegistration(
|
||||
@@ -309,14 +312,14 @@ public class ConnectionController(
|
||||
if (connection != null)
|
||||
{
|
||||
// Login existing user
|
||||
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
|
||||
callbackData.State.Split('|').FirstOrDefault() :
|
||||
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
|
||||
callbackData.State.Split('|').FirstOrDefault() :
|
||||
string.Empty;
|
||||
|
||||
|
||||
var challenge = await oidcService.CreateChallengeForUserAsync(
|
||||
userInfo,
|
||||
connection.Account,
|
||||
HttpContext,
|
||||
userInfo,
|
||||
connection.Account,
|
||||
HttpContext,
|
||||
deviceId ?? string.Empty);
|
||||
return Redirect($"/auth/callback?challenge={challenge.Id}");
|
||||
}
|
||||
@@ -341,7 +344,10 @@ public class ConnectionController(
|
||||
|
||||
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||
var loginToken = auth.CreateToken(loginSession);
|
||||
return Redirect($"/auth/callback?token={loginToken}");
|
||||
|
||||
var siteUrl = configuration["SiteUrl"];
|
||||
|
||||
return Redirect(siteUrl + $"/auth/callback?token={loginToken}");
|
||||
}
|
||||
|
||||
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
||||
@@ -355,18 +361,18 @@ public class ConnectionController(
|
||||
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
|
||||
break;
|
||||
case "POST" when request.HasFormContentType:
|
||||
{
|
||||
var form = await request.ReadFormAsync();
|
||||
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
|
||||
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
|
||||
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
|
||||
if (form.ContainsKey("user"))
|
||||
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
|
||||
{
|
||||
var form = await request.ReadFormAsync();
|
||||
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
|
||||
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
|
||||
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
|
||||
if (form.ContainsKey("user"))
|
||||
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@ public abstract class OidcService(
|
||||
{
|
||||
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
||||
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
||||
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
||||
RedirectUri = Configuration["SiteUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,4 +292,4 @@ public class OidcCallbackData
|
||||
public string IdToken { get; set; } = "";
|
||||
public string? State { get; set; }
|
||||
public string? RawData { get; set; }
|
||||
}
|
||||
}
|
||||
|
@@ -49,7 +49,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
24
DysonNetwork.Pass/DysonNetwork.Pass.sln
Normal file
24
DysonNetwork.Pass/DysonNetwork.Pass.sln
Normal file
@@ -0,0 +1,24 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonNetwork.Pass.csproj", "{0E8F6522-90DE-5BDE-7127-114E02C2C10F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {8DAB9031-CC04-4A1A-A05A-4ADFEBAB90A8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
2207
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.Designer.cs
generated
Normal file
2207
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSubscriptionGift : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_gifts",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
gifter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
recipient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
gift_code = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
subscription_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
base_price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
final_price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
redeemed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
redeemer_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
is_open_gift = table.Column<bool>(type: "boolean", nullable: false),
|
||||
payment_method = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
payment_details = table.Column<SnPaymentDetails>(type: "jsonb", nullable: false),
|
||||
coupon_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_gifts", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_gifts_accounts_gifter_id",
|
||||
column: x => x.gifter_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_gifts_accounts_recipient_id",
|
||||
column: x => x.recipient_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id");
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_gifts_accounts_redeemer_id",
|
||||
column: x => x.redeemer_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id");
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_gifts_wallet_coupons_coupon_id",
|
||||
column: x => x.coupon_id,
|
||||
principalTable: "wallet_coupons",
|
||||
principalColumn: "id");
|
||||
});
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_coupon_id",
|
||||
table: "wallet_gifts",
|
||||
column: "coupon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_gift_code",
|
||||
table: "wallet_gifts",
|
||||
column: "gift_code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_gifter_id",
|
||||
table: "wallet_gifts",
|
||||
column: "gifter_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_recipient_id",
|
||||
table: "wallet_gifts",
|
||||
column: "recipient_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_redeemer_id",
|
||||
table: "wallet_gifts",
|
||||
column: "redeemer_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
principalTable: "wallet_gifts",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_gifts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
}
|
||||
}
|
||||
}
|
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
2207
DysonNetwork.Pass/Migrations/20251003123103_RefactorSubscriptionRelation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RefactorSubscriptionRelation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "subscription_id",
|
||||
table: "wallet_gifts",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_gifts_subscription_id",
|
||||
table: "wallet_gifts",
|
||||
column: "subscription_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
|
||||
table: "wallet_gifts",
|
||||
column: "subscription_id",
|
||||
principalTable: "wallet_subscriptions",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_wallet_gifts_wallet_subscriptions_subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_wallet_gifts_subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "subscription_id",
|
||||
table: "wallet_gifts");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_gifts_gift_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "gift_id",
|
||||
principalTable: "wallet_gifts",
|
||||
principalColumn: "id");
|
||||
}
|
||||
}
|
||||
}
|
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
2355
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal file
99
DysonNetwork.Pass/Migrations/20251003152102_AddWalletFund.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWalletFund : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_funds",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
total_amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
split_type = table.Column<int>(type: "integer", nullable: false),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
message = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
creator_account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_funds", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_funds_accounts_creator_account_id",
|
||||
column: x => x.creator_account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_fund_recipients",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
fund_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
recipient_account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
is_received = table.Column<bool>(type: "boolean", nullable: false),
|
||||
received_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_fund_recipients", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_fund_recipients_accounts_recipient_account_id",
|
||||
column: x => x.recipient_account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_fund_recipients_wallet_funds_fund_id",
|
||||
column: x => x.fund_id,
|
||||
principalTable: "wallet_funds",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_fund_recipients_fund_id",
|
||||
table: "wallet_fund_recipients",
|
||||
column: "fund_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_fund_recipients_recipient_account_id",
|
||||
table: "wallet_fund_recipients",
|
||||
column: "recipient_account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_funds_creator_account_id",
|
||||
table: "wallet_funds",
|
||||
column: "creator_account_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_fund_recipients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_funds");
|
||||
}
|
||||
}
|
||||
}
|
2359
DysonNetwork.Pass/Migrations/20251008050851_AddUsernameColor.Designer.cs
generated
Normal file
2359
DysonNetwork.Pass/Migrations/20251008050851_AddUsernameColor.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUsernameColor : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<UsernameColor>(
|
||||
name: "username_color",
|
||||
table: "account_profiles",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "username_color",
|
||||
table: "account_profiles");
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,14 @@ app.MapDefaultEndpoints();
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await db.Database.MigrateAsync();
|
||||
try
|
||||
{
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
Console.WriteLine(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure application middleware pipeline
|
||||
@@ -47,4 +54,4 @@ app.ConfigureGrpcServices();
|
||||
|
||||
app.UseSwaggerManifest();
|
||||
|
||||
app.Run();
|
||||
app.Run();
|
||||
|
@@ -1,11 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Google.Protobuf;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.Net;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
@@ -16,59 +19,186 @@ public class BroadcastEventHandler(
|
||||
) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var paymentTask = HandlePaymentEventsAsync(stoppingToken);
|
||||
var webSocketTask = HandleWebSocketEventsAsync(stoppingToken);
|
||||
|
||||
await Task.WhenAll(paymentTask, webSocketTask);
|
||||
}
|
||||
|
||||
private async Task HandlePaymentEventsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var js = nats.CreateJetStreamContext();
|
||||
|
||||
await js.EnsureStreamCreated("payment_events", [PaymentOrderEventBase.Type]);
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
|
||||
new ConsumerConfig("pass_payment_handler"),
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync("payment_events",
|
||||
new ConsumerConfig("pass_payment_handler"),
|
||||
cancellationToken: stoppingToken);
|
||||
|
||||
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||
{
|
||||
PaymentOrderEvent? evt = null;
|
||||
try
|
||||
{
|
||||
evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data, GrpcTypeHelper.SerializerOptions);
|
||||
|
||||
|
||||
logger.LogInformation(
|
||||
"Received order event: {ProductIdentifier} {OrderId}",
|
||||
evt?.ProductIdentifier,
|
||||
evt?.OrderId
|
||||
);
|
||||
|
||||
if (evt?.ProductIdentifier is null ||
|
||||
!evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
|
||||
if (evt?.ProductIdentifier is null)
|
||||
continue;
|
||||
|
||||
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
|
||||
|
||||
var order = await db.PaymentOrders.FindAsync(
|
||||
[evt.OrderId],
|
||||
cancellationToken: stoppingToken
|
||||
);
|
||||
if (order is null)
|
||||
// Handle subscription orders
|
||||
if (
|
||||
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
|
||||
evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true
|
||||
)
|
||||
{
|
||||
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
|
||||
|
||||
var order = await db.PaymentOrders.FindAsync(
|
||||
[evt.OrderId],
|
||||
cancellationToken: stoppingToken
|
||||
);
|
||||
if (order is null)
|
||||
{
|
||||
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await subscriptions.HandleGiftOrder(order);
|
||||
|
||||
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
|
||||
{
|
||||
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
|
||||
|
||||
var order = await db.PaymentOrders.FindAsync(
|
||||
[evt.OrderId],
|
||||
cancellationToken: stoppingToken
|
||||
);
|
||||
if (order is null)
|
||||
{
|
||||
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await subscriptions.HandleSubscriptionOrder(order);
|
||||
|
||||
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not a subscription or gift order, skip
|
||||
continue;
|
||||
}
|
||||
|
||||
await subscriptions.HandleSubscriptionOrder(order);
|
||||
|
||||
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.", evt?.OrderId);
|
||||
logger.LogError(ex, "Error processing payment order event for order {OrderId}. Redelivering.",
|
||||
evt?.OrderId);
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleWebSocketEventsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var connectedTask = HandleConnectedEventsAsync(stoppingToken);
|
||||
var disconnectedTask = HandleDisconnectedEventsAsync(stoppingToken);
|
||||
|
||||
await Task.WhenAll(connectedTask, disconnectedTask);
|
||||
}
|
||||
|
||||
private async Task HandleConnectedEventsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>("websocket_connected", cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt =
|
||||
GrpcTypeHelper.ConvertByteStringToObject<WebSocketConnectedEvent>(ByteString.CopyFrom(msg.Data));
|
||||
|
||||
logger.LogInformation("Received WebSocket connected event for user {AccountId}, device {DeviceId}",
|
||||
evt.AccountId, evt.DeviceId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var accountEventService = scope.ServiceProvider.GetRequiredService<AccountEventService>();
|
||||
|
||||
var status = await accountEventService.GetStatus(evt.AccountId);
|
||||
|
||||
await nats.PublishAsync(
|
||||
AccountStatusUpdatedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
|
||||
{
|
||||
AccountId = evt.AccountId,
|
||||
Status = status,
|
||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}).ToByteArray()
|
||||
);
|
||||
|
||||
logger.LogInformation("Broadcasted status update for user {AccountId}", evt.AccountId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing WebSocket connected event");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectedEventsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>("websocket_disconnected",
|
||||
cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt =
|
||||
GrpcTypeHelper.ConvertByteStringToObject<WebSocketDisconnectedEvent>(ByteString.CopyFrom(msg.Data));
|
||||
|
||||
logger.LogInformation(
|
||||
"Received WebSocket disconnected event for user {AccountId}, device {DeviceId}, IsOffline: {IsOffline}",
|
||||
evt.AccountId, evt.DeviceId, evt.IsOffline
|
||||
);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var accountEventService = scope.ServiceProvider.GetRequiredService<AccountEventService>();
|
||||
|
||||
var status = await accountEventService.GetStatus(evt.AccountId);
|
||||
|
||||
await nats.PublishAsync(
|
||||
AccountStatusUpdatedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new AccountStatusUpdatedEvent
|
||||
{
|
||||
AccountId = evt.AccountId,
|
||||
Status = status,
|
||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
}).ToByteArray()
|
||||
);
|
||||
|
||||
logger.LogInformation("Broadcasted status update for user {AccountId}", evt.AccountId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing WebSocket disconnected event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -46,6 +46,26 @@ public static class ScheduledJobsConfiguration
|
||||
.WithIntervalInMinutes(30)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var giftCleanupJob = new JobKey("GiftCleanup");
|
||||
q.AddJob<GiftCleanupJob>(opts => opts.WithIdentity(giftCleanupJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(giftCleanupJob)
|
||||
.WithIdentity("GiftCleanupTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInHours(1)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var fundExpirationJob = new JobKey("FundExpiration");
|
||||
q.AddJob<FundExpirationJob>(opts => opts.WithIdentity(fundExpirationJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(fundExpirationJob)
|
||||
.WithIdentity("FundExpirationTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInHours(1)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal file
28
DysonNetwork.Pass/Wallet/FundExpirationJob.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class FundExpirationJob(
|
||||
AppDatabase db,
|
||||
PaymentService paymentService,
|
||||
ILogger<FundExpirationJob> logger
|
||||
) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Starting fund expiration job...");
|
||||
|
||||
try
|
||||
{
|
||||
await paymentService.ProcessExpiredFundsAsync();
|
||||
logger.LogInformation("Successfully processed expired funds");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing expired funds");
|
||||
}
|
||||
}
|
||||
}
|
40
DysonNetwork.Pass/Wallet/GiftCleanupJob.cs
Normal file
40
DysonNetwork.Pass/Wallet/GiftCleanupJob.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class GiftCleanupJob(
|
||||
AppDatabase db,
|
||||
ILogger<GiftCleanupJob> logger
|
||||
) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Starting gift cleanup job...");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
// Clean up gifts that are in Created status and older than 24 hours
|
||||
var cutoffTime = now.Minus(Duration.FromHours(24));
|
||||
|
||||
var oldCreatedGifts = await db.WalletGifts
|
||||
.Where(g => g.Status == GiftStatus.Created)
|
||||
.Where(g => g.CreatedAt < cutoffTime)
|
||||
.ToListAsync();
|
||||
|
||||
if (oldCreatedGifts.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No old created gifts to clean up");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Found {Count} old created gifts to clean up", oldCreatedGifts.Count);
|
||||
|
||||
// Remove the gifts
|
||||
db.WalletGifts.RemoveRange(oldCreatedGifts);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Successfully cleaned up {Count} old created gifts", oldCreatedGifts.Count);
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -8,36 +8,87 @@ namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/orders")]
|
||||
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
|
||||
public class OrderController(
|
||||
PaymentService payment,
|
||||
Pass.Auth.AuthService auth,
|
||||
AppDatabase db,
|
||||
CustomAppService.CustomAppServiceClient customApps
|
||||
) : ControllerBase
|
||||
{
|
||||
public class CreateOrderRequest
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public decimal Amount { get; set; }
|
||||
public string? Remarks { get; set; }
|
||||
public string? ProductIdentifier { get; set; }
|
||||
public Dictionary<string, object>? Meta { get; set; }
|
||||
public int DurationHours { get; set; } = 24;
|
||||
|
||||
public string ClientId { get; set; } = null!;
|
||||
public string ClientSecret { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SnWalletOrder>> CreateOrder([FromBody] CreateOrderRequest request)
|
||||
{
|
||||
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
|
||||
if (clientResp.App is null) return BadRequest("Client not found");
|
||||
var client = SnCustomApp.FromProtoValue(clientResp.App);
|
||||
|
||||
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
|
||||
{
|
||||
AppId = client.Id.ToString(),
|
||||
Secret = request.ClientSecret,
|
||||
});
|
||||
if (!secret.Valid) return BadRequest("Invalid client secret");
|
||||
|
||||
var order = await payment.CreateOrderAsync(
|
||||
default,
|
||||
request.Currency,
|
||||
request.Amount,
|
||||
NodaTime.Duration.FromHours(request.DurationHours),
|
||||
request.ClientId,
|
||||
request.ProductIdentifier,
|
||||
request.Remarks,
|
||||
request.Meta
|
||||
);
|
||||
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<SnWalletOrder>> GetOrderById(Guid id)
|
||||
{
|
||||
var order = await db.PaymentOrders.FindAsync(id);
|
||||
|
||||
|
||||
if (order == null)
|
||||
return NotFound();
|
||||
|
||||
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
|
||||
public class PayOrderRequest
|
||||
{
|
||||
public string PinCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pay")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletOrder>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
|
||||
// Validate PIN code
|
||||
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
|
||||
return StatusCode(403, "Invalid PIN Code");
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// Get the wallet for the current user
|
||||
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
|
||||
if (wallet == null)
|
||||
return BadRequest("Wallet was not found.");
|
||||
|
||||
|
||||
// Pay the order
|
||||
var paidOrder = await payment.PayOrderAsync(id, wallet);
|
||||
return Ok(paidOrder);
|
||||
@@ -47,9 +98,46 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateOrderStatusRequest
|
||||
{
|
||||
public string ClientId { get; set; } = null!;
|
||||
public string ClientSecret { get; set; } = null!;
|
||||
public Shared.Models.OrderStatus Status { get; set; }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
public async Task<ActionResult<SnWalletOrder>> UpdateOrderStatus(Guid id, [FromBody] UpdateOrderStatusRequest request)
|
||||
{
|
||||
var clientResp = await customApps.GetCustomAppAsync(new GetCustomAppRequest { Slug = request.ClientId });
|
||||
if (clientResp.App is null) return BadRequest("Client not found");
|
||||
var client = SnCustomApp.FromProtoValue(clientResp.App);
|
||||
|
||||
var secret = await customApps.CheckCustomAppSecretAsync(new CheckCustomAppSecretRequest
|
||||
{
|
||||
AppId = client.Id.ToString(),
|
||||
Secret = request.ClientSecret,
|
||||
});
|
||||
if (!secret.Valid) return BadRequest("Invalid client secret");
|
||||
|
||||
var order = await db.PaymentOrders.FindAsync(id);
|
||||
|
||||
if (order == null)
|
||||
return NotFound();
|
||||
|
||||
if (order.AppIdentifier != request.ClientId)
|
||||
{
|
||||
return BadRequest("Order does not belong to this client.");
|
||||
}
|
||||
|
||||
if (request.Status != Shared.Models.OrderStatus.Finished && request.Status != Shared.Models.OrderStatus.Cancelled)
|
||||
return BadRequest("Invalid status. Available statuses are Finished, Cancelled.");
|
||||
|
||||
|
||||
order.Status = request.Status;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(order);
|
||||
}
|
||||
}
|
||||
|
||||
public class PayOrderRequest
|
||||
{
|
||||
public string PinCode { get; set; } = string.Empty;
|
||||
}
|
@@ -439,12 +439,346 @@ public class PaymentService(
|
||||
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
|
||||
}
|
||||
|
||||
return await CreateTransactionAsync(
|
||||
// Calculate transfer fee (5%)
|
||||
decimal fee = Math.Round(amount * 0.05m, 2);
|
||||
decimal finalCost = amount + fee;
|
||||
|
||||
// Make sure the account has sufficient balanace for both fee and the transfer
|
||||
var (payerPocket, isNewlyCreated) =
|
||||
await wat.GetOrCreateWalletPocketAsync(payerWallet.Id, currency, amount);
|
||||
|
||||
if (isNewlyCreated || payerPocket.Amount < finalCost)
|
||||
throw new InvalidOperationException("Insufficient funds");
|
||||
|
||||
// Create main transfer transaction
|
||||
var transaction = await CreateTransactionAsync(
|
||||
payerWallet.Id,
|
||||
payeeWallet.Id,
|
||||
currency,
|
||||
amount,
|
||||
$"Transfer from account {payerAccountId} to {payeeAccountId}",
|
||||
Shared.Models.TransactionType.Transfer);
|
||||
|
||||
// Create fee transaction (to system)
|
||||
await CreateTransactionAsync(
|
||||
payerWallet.Id,
|
||||
null,
|
||||
currency,
|
||||
fee,
|
||||
$"Transfer fee for transaction #{transaction.Id}",
|
||||
Shared.Models.TransactionType.System);
|
||||
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SnWalletFund> CreateFundAsync(
|
||||
Guid creatorAccountId,
|
||||
List<Guid> recipientAccountIds,
|
||||
string currency,
|
||||
decimal totalAmount,
|
||||
Shared.Models.FundSplitType splitType,
|
||||
string? message = null,
|
||||
Duration? expiration = null)
|
||||
{
|
||||
if (recipientAccountIds.Count == 0)
|
||||
throw new ArgumentException("At least one recipient is required");
|
||||
|
||||
if (totalAmount <= 0)
|
||||
throw new ArgumentException("Total amount must be positive");
|
||||
|
||||
// Validate all recipient accounts exist and have wallets
|
||||
var recipientWallets = new List<SnWallet>();
|
||||
foreach (var accountId in recipientAccountIds)
|
||||
{
|
||||
var wallet = await wat.GetWalletAsync(accountId);
|
||||
if (wallet == null)
|
||||
throw new InvalidOperationException($"Wallet not found for recipient account {accountId}");
|
||||
recipientWallets.Add(wallet);
|
||||
}
|
||||
|
||||
// Check creator has sufficient funds
|
||||
var creatorWallet = await wat.GetWalletAsync(creatorAccountId);
|
||||
if (creatorWallet == null)
|
||||
throw new InvalidOperationException($"Creator wallet not found for account {creatorAccountId}");
|
||||
|
||||
var (creatorPocket, _) = await wat.GetOrCreateWalletPocketAsync(creatorWallet.Id, currency);
|
||||
if (creatorPocket.Amount < totalAmount)
|
||||
throw new InvalidOperationException("Insufficient funds");
|
||||
|
||||
// Calculate amounts for each recipient
|
||||
var recipientAmounts = splitType switch
|
||||
{
|
||||
Shared.Models.FundSplitType.Even => SplitEvenly(totalAmount, recipientAccountIds.Count),
|
||||
Shared.Models.FundSplitType.Random => SplitRandomly(totalAmount, recipientAccountIds.Count),
|
||||
_ => throw new ArgumentException("Invalid split type")
|
||||
};
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var fund = new SnWalletFund
|
||||
{
|
||||
CreatorAccountId = creatorAccountId,
|
||||
Currency = currency,
|
||||
TotalAmount = totalAmount,
|
||||
SplitType = splitType,
|
||||
Message = message,
|
||||
ExpiredAt = now.Plus(expiration ?? Duration.FromHours(24)),
|
||||
Recipients = recipientAccountIds.Select((accountId, index) => new SnWalletFundRecipient
|
||||
{
|
||||
RecipientAccountId = accountId,
|
||||
Amount = recipientAmounts[index]
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// Deduct from creator's wallet
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == creatorPocket.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Amount, p => p.Amount - totalAmount));
|
||||
|
||||
db.WalletFunds.Add(fund);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Load the fund with account data including profiles
|
||||
var createdFund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.FirstOrDefaultAsync(f => f.Id == fund.Id);
|
||||
|
||||
return createdFund!;
|
||||
}
|
||||
|
||||
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100; // Round down to 2 decimal places
|
||||
var remainder = totalAmount - (baseAmount * recipientCount);
|
||||
|
||||
var amounts = new List<decimal>();
|
||||
for (int i = 0; i < recipientCount; i++)
|
||||
{
|
||||
var amount = baseAmount;
|
||||
if (i < remainder * 100) // Distribute remainder as 0.01 increments
|
||||
amount += 0.01m;
|
||||
amounts.Add(amount);
|
||||
}
|
||||
|
||||
return amounts;
|
||||
}
|
||||
|
||||
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var random = new Random();
|
||||
var amounts = new List<decimal>();
|
||||
|
||||
// Generate random amounts that sum to total
|
||||
decimal remaining = totalAmount;
|
||||
for (int i = 0; i < recipientCount - 1; i++)
|
||||
{
|
||||
// Ensure each recipient gets at least 0.01 and leave enough for remaining recipients
|
||||
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
|
||||
var minAmount = 0.01m;
|
||||
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
|
||||
amounts.Add(amount);
|
||||
remaining -= amount;
|
||||
}
|
||||
|
||||
// Last recipient gets the remainder
|
||||
amounts.Add(Math.Round(remaining, 2));
|
||||
|
||||
return amounts;
|
||||
}
|
||||
|
||||
public async Task<SnWalletTransaction> ReceiveFundAsync(Guid recipientAccountId, Guid fundId)
|
||||
{
|
||||
var fund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.FirstOrDefaultAsync(f => f.Id == fundId);
|
||||
|
||||
if (fund == null)
|
||||
throw new InvalidOperationException("Fund not found");
|
||||
|
||||
if (fund.Status == Shared.Models.FundStatus.Expired || fund.Status == Shared.Models.FundStatus.Refunded)
|
||||
throw new InvalidOperationException("Fund is no longer available");
|
||||
|
||||
var recipient = fund.Recipients.FirstOrDefault(r => r.RecipientAccountId == recipientAccountId);
|
||||
if (recipient == null)
|
||||
throw new InvalidOperationException("You are not a recipient of this fund");
|
||||
|
||||
if (recipient.IsReceived)
|
||||
throw new InvalidOperationException("You have already received this fund");
|
||||
|
||||
var recipientWallet = await wat.GetWalletAsync(recipientAccountId);
|
||||
if (recipientWallet == null)
|
||||
throw new InvalidOperationException("Recipient wallet not found");
|
||||
|
||||
// Create transaction to transfer funds to recipient
|
||||
var transaction = await CreateTransactionAsync(
|
||||
payerWalletId: null, // System transfer
|
||||
payeeWalletId: recipientWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: recipient.Amount,
|
||||
remarks: $"Received fund portion from {fund.CreatorAccountId}",
|
||||
type: Shared.Models.TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
|
||||
// Mark as received
|
||||
recipient.IsReceived = true;
|
||||
recipient.ReceivedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Update fund status
|
||||
var allReceived = fund.Recipients.All(r => r.IsReceived);
|
||||
if (allReceived)
|
||||
fund.Status = Shared.Models.FundStatus.FullyReceived;
|
||||
else
|
||||
fund.Status = Shared.Models.FundStatus.PartiallyReceived;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task ProcessExpiredFundsAsync()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var expiredFunds = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.Where(f => f.Status == Shared.Models.FundStatus.Created || f.Status == Shared.Models.FundStatus.PartiallyReceived)
|
||||
.Where(f => f.ExpiredAt < now)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var fund in expiredFunds)
|
||||
{
|
||||
// Calculate unclaimed amount
|
||||
var unclaimedAmount = fund.Recipients
|
||||
.Where(r => !r.IsReceived)
|
||||
.Sum(r => r.Amount);
|
||||
|
||||
if (unclaimedAmount > 0)
|
||||
{
|
||||
// Refund to creator
|
||||
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
|
||||
if (creatorWallet != null)
|
||||
{
|
||||
await CreateTransactionAsync(
|
||||
payerWalletId: null, // System refund
|
||||
payeeWalletId: creatorWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: unclaimedAmount,
|
||||
remarks: $"Refund for expired fund {fund.Id}",
|
||||
type: Shared.Models.TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fund.Status = Shared.Models.FundStatus.Expired;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
|
||||
{
|
||||
var wallet = await wat.GetWalletAsync(accountId);
|
||||
if (wallet == null)
|
||||
throw new InvalidOperationException("Wallet not found");
|
||||
|
||||
var query = db.PaymentTransactions
|
||||
.Where(t => t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id);
|
||||
|
||||
if (startDate.HasValue)
|
||||
query = query.Where(t => t.CreatedAt >= Instant.FromDateTimeUtc(startDate.Value.ToUniversalTime()));
|
||||
if (endDate.HasValue)
|
||||
query = query.Where(t => t.CreatedAt <= Instant.FromDateTimeUtc(endDate.Value.ToUniversalTime()));
|
||||
|
||||
var transactions = await query.ToListAsync();
|
||||
|
||||
var overview = new WalletOverview
|
||||
{
|
||||
AccountId = accountId,
|
||||
StartDate = startDate?.ToString("O"),
|
||||
EndDate = endDate?.ToString("O"),
|
||||
Summary = new Dictionary<string, TransactionSummary>()
|
||||
};
|
||||
|
||||
// Group transactions by type and currency
|
||||
var groupedTransactions = transactions
|
||||
.GroupBy(t => new { t.Type, t.Currency })
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var group in groupedTransactions)
|
||||
{
|
||||
var typeName = group.Key.Type.ToString();
|
||||
var currency = group.Key.Currency;
|
||||
|
||||
if (!overview.Summary.ContainsKey(typeName))
|
||||
{
|
||||
overview.Summary[typeName] = new TransactionSummary
|
||||
{
|
||||
Type = typeName,
|
||||
Currencies = new Dictionary<string, CurrencySummary>()
|
||||
};
|
||||
}
|
||||
|
||||
var currencySummary = new CurrencySummary
|
||||
{
|
||||
Currency = currency,
|
||||
Income = 0,
|
||||
Spending = 0,
|
||||
Net = 0
|
||||
};
|
||||
|
||||
foreach (var transaction in group.Value)
|
||||
{
|
||||
if (transaction.PayeeWalletId == wallet.Id)
|
||||
{
|
||||
// Money coming in
|
||||
currencySummary.Income += transaction.Amount;
|
||||
}
|
||||
else if (transaction.PayerWalletId == wallet.Id)
|
||||
{
|
||||
// Money going out
|
||||
currencySummary.Spending += transaction.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
currencySummary.Net = currencySummary.Income - currencySummary.Spending;
|
||||
overview.Summary[typeName].Currencies[currency] = currencySummary;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
overview.TotalIncome = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Income));
|
||||
overview.TotalSpending = overview.Summary.Values.Sum(s => s.Currencies.Values.Sum(c => c.Spending));
|
||||
overview.NetTotal = overview.TotalIncome - overview.TotalSpending;
|
||||
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
|
||||
public class WalletOverview
|
||||
{
|
||||
public Guid AccountId { get; set; }
|
||||
public string? StartDate { get; set; }
|
||||
public string? EndDate { get; set; }
|
||||
public Dictionary<string, TransactionSummary> Summary { get; set; } = new();
|
||||
public decimal TotalIncome { get; set; }
|
||||
public decimal TotalSpending { get; set; }
|
||||
public decimal NetTotal { get; set; }
|
||||
}
|
||||
|
||||
public class TransactionSummary
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public Dictionary<string, CurrencySummary> Currencies { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CurrencySummary
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public decimal Income { get; set; }
|
||||
public decimal Spending { get; set; }
|
||||
public decimal Net { get; set; }
|
||||
}
|
||||
|
335
DysonNetwork.Pass/Wallet/SubscriptionGiftController.cs
Normal file
335
DysonNetwork.Pass/Wallet/SubscriptionGiftController.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Models;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/subscriptions/gifts")]
|
||||
public class SubscriptionGiftController(
|
||||
SubscriptionService subscriptions,
|
||||
AppDatabase db
|
||||
) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists gifts purchased by the current user.
|
||||
/// </summary>
|
||||
[HttpGet("sent")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnWalletGift>>> ListSentGifts(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var query = await subscriptions.GetGiftsByGifterAsync(currentUser.Id);
|
||||
var totalCount = query.Count;
|
||||
|
||||
var gifts = query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
return gifts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists gifts received by the current user (both direct and redeemed open gifts).
|
||||
/// </summary>
|
||||
[HttpGet("received")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnWalletGift>>> ListReceivedGifts(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var gifts = await subscriptions.GetGiftsByRecipientAsync(currentUser.Id);
|
||||
var totalCount = gifts.Count;
|
||||
|
||||
gifts = gifts
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
return gifts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific gift by ID (only if user is the gifter or recipient).
|
||||
/// </summary>
|
||||
[HttpGet("{giftId}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletGift>> GetGift(Guid giftId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var gift = await db.WalletGifts
|
||||
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Subscription)
|
||||
.Include(g => g.Coupon)
|
||||
.FirstOrDefaultAsync(g => g.Id == giftId);
|
||||
|
||||
if (gift is null) return NotFound();
|
||||
if (gift.GifterId != currentUser.Id && gift.RecipientId != currentUser.Id &&
|
||||
!(gift.IsOpenGift && gift.RedeemerId == currentUser.Id))
|
||||
return NotFound();
|
||||
|
||||
return gift;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a gift code is valid and redeemable.
|
||||
/// </summary>
|
||||
[HttpGet("check/{giftCode}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<GiftCheckResponse>> CheckGiftCode(string giftCode)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var gift = await subscriptions.GetGiftByCodeAsync(giftCode);
|
||||
if (gift is null) return NotFound("Gift code not found.");
|
||||
|
||||
var canRedeem = false;
|
||||
var error = "";
|
||||
|
||||
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
|
||||
{
|
||||
error = gift.Status switch
|
||||
{
|
||||
DysonNetwork.Shared.Models.GiftStatus.Created => "Gift has not been sent yet.",
|
||||
DysonNetwork.Shared.Models.GiftStatus.Redeemed => "Gift has already been redeemed.",
|
||||
DysonNetwork.Shared.Models.GiftStatus.Expired => "Gift has expired.",
|
||||
DysonNetwork.Shared.Models.GiftStatus.Cancelled => "Gift has been cancelled.",
|
||||
_ => "Gift is not redeemable."
|
||||
};
|
||||
}
|
||||
else if (gift.ExpiresAt < SystemClock.Instance.GetCurrentInstant())
|
||||
{
|
||||
error = "Gift has expired.";
|
||||
}
|
||||
else if (!gift.IsOpenGift && gift.RecipientId != currentUser.Id)
|
||||
{
|
||||
error = "This gift is intended for someone else.";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if user already has this subscription type
|
||||
var subscriptionInfo = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
|
||||
if (subscriptionInfo != null)
|
||||
{
|
||||
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||
? SubscriptionTypeData.SubscriptionDict
|
||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||
.Select(s => s.Value.Identifier)
|
||||
.ToArray()
|
||||
: [gift.SubscriptionIdentifier];
|
||||
|
||||
var existingSubscription =
|
||||
await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
|
||||
if (existingSubscription is not null)
|
||||
{
|
||||
error = "You already have an active subscription of this type.";
|
||||
}
|
||||
else
|
||||
{
|
||||
canRedeem = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new GiftCheckResponse
|
||||
{
|
||||
GiftCode = giftCode,
|
||||
SubscriptionIdentifier = gift.SubscriptionIdentifier,
|
||||
CanRedeem = canRedeem,
|
||||
Error = error,
|
||||
Message = gift.Message
|
||||
};
|
||||
}
|
||||
|
||||
public class GiftCheckResponse
|
||||
{
|
||||
public string GiftCode { get; set; } = null!;
|
||||
public string SubscriptionIdentifier { get; set; } = null!;
|
||||
public bool CanRedeem { get; set; }
|
||||
public string Error { get; set; } = null!;
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public class PurchaseGiftRequest
|
||||
{
|
||||
[Required] public string SubscriptionIdentifier { get; set; } = null!;
|
||||
public Guid? RecipientId { get; set; }
|
||||
[Required] public string PaymentMethod { get; set; } = null!;
|
||||
[Required] public SnPaymentDetails PaymentDetails { get; set; } = null!;
|
||||
public string? Message { get; set; }
|
||||
public string? Coupon { get; set; }
|
||||
public int? GiftDurationDays { get; set; } = 30; // Gift expires in 30 days by default
|
||||
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
|
||||
}
|
||||
|
||||
const int MinimumAccountLevel = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Purchases a gift subscription.
|
||||
/// </summary>
|
||||
[HttpPost("purchase")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletGift>> PurchaseGift([FromBody] PurchaseGiftRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
if (currentUser.Profile.Level < MinimumAccountLevel)
|
||||
{
|
||||
if (currentUser.PerkSubscription is null)
|
||||
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
|
||||
}
|
||||
|
||||
Duration? giftDuration = null;
|
||||
if (request.GiftDurationDays.HasValue)
|
||||
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);
|
||||
|
||||
Duration? subscriptionDuration = null;
|
||||
if (request.SubscriptionDurationDays.HasValue)
|
||||
subscriptionDuration = Duration.FromDays(request.SubscriptionDurationDays.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var gift = await subscriptions.PurchaseGiftAsync(
|
||||
currentUser,
|
||||
request.RecipientId,
|
||||
request.SubscriptionIdentifier,
|
||||
request.PaymentMethod,
|
||||
request.PaymentDetails,
|
||||
request.Message,
|
||||
request.Coupon,
|
||||
giftDuration,
|
||||
subscriptionDuration
|
||||
);
|
||||
|
||||
return gift;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class RedeemGiftRequest
|
||||
{
|
||||
[Required] public string GiftCode { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redeems a gift using its code, creating a subscription for the current user.
|
||||
/// </summary>
|
||||
[HttpPost("redeem")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<RedeemGiftResponse>> RedeemGift([FromBody] RedeemGiftRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var (gift, subscription) = await subscriptions.RedeemGiftAsync(currentUser, request.GiftCode);
|
||||
|
||||
return new RedeemGiftResponse
|
||||
{
|
||||
Gift = gift,
|
||||
Subscription = subscription
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class RedeemGiftResponse
|
||||
{
|
||||
public SnWalletGift Gift { get; set; } = null!;
|
||||
public SnWalletSubscription Subscription { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a gift as sent (ready for redemption).
|
||||
/// </summary>
|
||||
[HttpPost("{giftId}/send")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletGift>> SendGift(Guid giftId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var gift = await subscriptions.MarkGiftAsSentAsync(giftId, currentUser.Id);
|
||||
return gift;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a gift before it's redeemed.
|
||||
/// </summary>
|
||||
[HttpPost("{giftId}/cancel")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletGift>> CancelGift(Guid giftId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var gift = await subscriptions.CancelGiftAsync(giftId, currentUser.Id);
|
||||
return gift;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an order for an unpaid gift.
|
||||
/// </summary>
|
||||
[HttpPost("{giftId}/order")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletOrder>> CreateGiftOrder(Guid giftId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var order = await subscriptions.CreateGiftOrder(currentUser.Id, giftId);
|
||||
return order;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -42,6 +42,7 @@ public class SubscriptionService(
|
||||
: null;
|
||||
if (subscriptionInfo is null)
|
||||
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
|
||||
|
||||
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||
? SubscriptionTypeData.SubscriptionDict
|
||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||
@@ -57,36 +58,33 @@ public class SubscriptionService(
|
||||
if (existingSubscription is not null)
|
||||
return existingSubscription;
|
||||
|
||||
if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
|
||||
if (profile.Level < subscriptionInfo.RequiredLevel)
|
||||
throw new InvalidOperationException(
|
||||
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
|
||||
);
|
||||
}
|
||||
// Batch database queries for account profile and coupon to reduce round trips
|
||||
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
|
||||
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
|
||||
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
|
||||
|
||||
if (isFreeTrial)
|
||||
{
|
||||
var prevFreeTrial = await db.WalletSubscriptions
|
||||
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
|
||||
.FirstOrDefaultAsync();
|
||||
if (prevFreeTrial is not null)
|
||||
throw new InvalidOperationException("Free trial already exists.");
|
||||
}
|
||||
var prevFreeTrialTask = isFreeTrial
|
||||
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
|
||||
: Task.FromResult((SnWalletSubscription?)null);
|
||||
|
||||
SnWalletCoupon? couponData = null;
|
||||
if (coupon is not null)
|
||||
{
|
||||
var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty;
|
||||
couponData = await db.WalletCoupons
|
||||
.Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon))
|
||||
.FirstOrDefaultAsync();
|
||||
if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
|
||||
}
|
||||
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
|
||||
var couponTask = coupon != null
|
||||
? db.WalletCoupons.FirstOrDefaultAsync(c =>
|
||||
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
|
||||
: Task.FromResult((SnWalletCoupon?)null);
|
||||
|
||||
// Await batched queries
|
||||
var profile = await accountProfileTask;
|
||||
var prevFreeTrial = await prevFreeTrialTask;
|
||||
var couponData = await couponTask;
|
||||
|
||||
// Validation checks
|
||||
|
||||
if (isFreeTrial && prevFreeTrial != null)
|
||||
throw new InvalidOperationException("Free trial already exists.");
|
||||
|
||||
if (coupon != null && couponData is null)
|
||||
throw new InvalidOperationException($"Coupon {coupon} was not found.");
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var subscription = new SnWalletSubscription
|
||||
@@ -252,6 +250,14 @@ public class SubscriptionService(
|
||||
: null;
|
||||
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
|
||||
|
||||
if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
|
||||
if (profile is null) throw new InvalidOperationException("Account must have a profile");
|
||||
if (profile.Level < subscriptionInfo.RequiredLevel)
|
||||
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
|
||||
}
|
||||
|
||||
return await payment.CreateOrderAsync(
|
||||
null,
|
||||
subscriptionInfo.Currency,
|
||||
@@ -266,6 +272,41 @@ public class SubscriptionService(
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a gift order for an unpaid gift.
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID of the gifter.</param>
|
||||
/// <param name="giftId">The unique identifier for the gift.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the created gift order.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the gift is not found or not in payable status.</exception>
|
||||
public async Task<SnWalletOrder> CreateGiftOrder(Guid accountId, Guid giftId)
|
||||
{
|
||||
var gift = await db.WalletGifts
|
||||
.Where(g => g.Id == giftId && g.GifterId == accountId)
|
||||
.Where(g => g.Status == DysonNetwork.Shared.Models.GiftStatus.Created)
|
||||
.Include(g => g.Coupon)
|
||||
.FirstOrDefaultAsync();
|
||||
if (gift is null) throw new InvalidOperationException("No matching gift found.");
|
||||
|
||||
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
|
||||
.TryGetValue(gift.SubscriptionIdentifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
|
||||
|
||||
return await payment.CreateOrderAsync(
|
||||
null,
|
||||
subscriptionInfo.Currency,
|
||||
gift.FinalPrice,
|
||||
appIdentifier: "gift",
|
||||
productIdentifier: gift.SubscriptionIdentifier,
|
||||
meta: new Dictionary<string, object>()
|
||||
{
|
||||
["gift_id"] = gift.Id.ToString()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order)
|
||||
{
|
||||
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
|
||||
@@ -285,14 +326,11 @@ public class SubscriptionService(
|
||||
|
||||
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
|
||||
// Calculate original cycle duration and extend from the current ended date
|
||||
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
|
||||
|
||||
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
|
||||
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
|
||||
|
||||
subscription.RenewalAt = nextRenewalAt;
|
||||
subscription.EndedAt = nextEndedAt;
|
||||
subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
|
||||
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
|
||||
}
|
||||
|
||||
subscription.Status = Shared.Models.SubscriptionStatus.Active;
|
||||
@@ -305,6 +343,36 @@ public class SubscriptionService(
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
|
||||
{
|
||||
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["gift_id"] is not JsonElement giftIdJson)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
|
||||
var giftId = Guid.TryParse(giftIdJson.ToString(), out var parsedGiftId)
|
||||
? parsedGiftId
|
||||
: Guid.Empty;
|
||||
if (giftId == Guid.Empty)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
var gift = await db.WalletGifts
|
||||
.Where(g => g.Id == giftId)
|
||||
.Include(g => g.Coupon)
|
||||
.FirstOrDefaultAsync();
|
||||
if (gift is null)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
|
||||
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
|
||||
throw new InvalidOperationException("Gift is not in payable status.");
|
||||
|
||||
// Mark gift as sent after payment
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
|
||||
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
db.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return gift;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of expired subscriptions to reflect their current state.
|
||||
/// This helps maintain accurate subscription records and is typically called periodically.
|
||||
@@ -326,16 +394,19 @@ public class SubscriptionService(
|
||||
if (expiredSubscriptions.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Mark as expired
|
||||
foreach (var subscription in expiredSubscriptions)
|
||||
{
|
||||
subscription.Status = Shared.Models.SubscriptionStatus.Expired;
|
||||
|
||||
// Clear the cache for this subscription
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Batch invalidate caches for better performance
|
||||
var cacheTasks = expiredSubscriptions.Select(subscription =>
|
||||
cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"));
|
||||
await Task.WhenAll(cacheTasks);
|
||||
|
||||
return expiredSubscriptions.Count;
|
||||
}
|
||||
|
||||
@@ -379,10 +450,11 @@ public class SubscriptionService(
|
||||
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
|
||||
{
|
||||
// Create a unique cache key for this subscription
|
||||
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers)));
|
||||
var hashIdentifier = Convert.ToHexStringLower(hashBytes);
|
||||
var identifierPart = identifiers.Length == 1
|
||||
? identifiers[0]
|
||||
: Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))));
|
||||
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{hashIdentifier}";
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}";
|
||||
|
||||
// Try to get the subscription from cache first
|
||||
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
|
||||
@@ -443,17 +515,24 @@ public class SubscriptionService(
|
||||
var missingAccountIds = new List<Guid>();
|
||||
|
||||
// Try to get the subscription from cache first
|
||||
foreach (var accountId in accountIds)
|
||||
var cacheTasks = accountIds.Select(async accountId =>
|
||||
{
|
||||
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
|
||||
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
|
||||
return (accountId, found, cachedSubscription);
|
||||
});
|
||||
|
||||
var cacheResults = await Task.WhenAll(cacheTasks);
|
||||
|
||||
foreach (var (accountId, found, cachedSubscription) in cacheResults)
|
||||
{
|
||||
if (found && cachedSubscription != null)
|
||||
result[accountId] = cachedSubscription;
|
||||
else
|
||||
missingAccountIds.Add(accountId);
|
||||
}
|
||||
|
||||
if (missingAccountIds.Count <= 0) return result;
|
||||
if (missingAccountIds.Count == 0) return result;
|
||||
|
||||
// If not in cache, get from database
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
@@ -464,18 +543,462 @@ public class SubscriptionService(
|
||||
.Where(s => s.EndedAt == null || s.EndedAt > now)
|
||||
.OrderByDescending(s => s.BegunAt)
|
||||
.ToListAsync();
|
||||
subscriptions = subscriptions.Where(s => s.IsAvailable).ToList();
|
||||
|
||||
// Group the subscriptions by account id
|
||||
foreach (var subscription in subscriptions)
|
||||
// Group by account and select latest available subscription
|
||||
var groupedSubscriptions = subscriptions
|
||||
.Where(s => s.IsAvailable)
|
||||
.GroupBy(s => s.AccountId)
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// Update results and batch cache operations
|
||||
var cacheSetTasks = new List<Task>();
|
||||
foreach (var kvp in groupedSubscriptions)
|
||||
{
|
||||
result[subscription.AccountId] = subscription;
|
||||
|
||||
// Cache the result if found (with 30 minutes expiry)
|
||||
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}";
|
||||
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
|
||||
result[kvp.Key] = kvp.Value;
|
||||
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
|
||||
cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
|
||||
await Task.WhenAll(cacheSetTasks);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purchases a gift subscription that can be redeemed by another user.
|
||||
/// </summary>
|
||||
/// <param name="gifter">The account purchasing the gift.</param>
|
||||
/// <param name="recipientId">Optional specific recipient. If null, creates an open gift anyone can redeem.</param>
|
||||
/// <param name="subscriptionIdentifier">The subscription type being gifted.</param>
|
||||
/// <param name="paymentMethod">Payment method used by the gifter.</param>
|
||||
/// <param name="paymentDetails">Payment details from the gifter.</param>
|
||||
/// <param name="message">Optional personal message from the gifter.</param>
|
||||
/// <param name="coupon">Optional coupon code for discount.</param>
|
||||
/// <param name="giftDuration">How long the gift can be redeemed (default 30 days).</param>
|
||||
/// <param name="cycleDuration">The duration of the subscription once redeemed (default 30 days).</param>
|
||||
/// <returns>The created gift record.</returns>
|
||||
public async Task<SnWalletGift> PurchaseGiftAsync(
|
||||
SnAccount gifter,
|
||||
Guid? recipientId,
|
||||
string subscriptionIdentifier,
|
||||
string paymentMethod,
|
||||
SnPaymentDetails paymentDetails,
|
||||
string? message = null,
|
||||
string? coupon = null,
|
||||
Duration? giftDuration = null,
|
||||
Duration? cycleDuration = null)
|
||||
{
|
||||
// Validate subscription exists
|
||||
var subscriptionInfo = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionInfo is null)
|
||||
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
|
||||
$@"Subscription {subscriptionIdentifier} was not found.");
|
||||
|
||||
// Check if recipient account exists (if specified)
|
||||
SnAccount? recipient = null;
|
||||
if (recipientId.HasValue)
|
||||
{
|
||||
recipient = await db.Accounts
|
||||
.Where(a => a.Id == recipientId.Value)
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync();
|
||||
if (recipient is null)
|
||||
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
|
||||
}
|
||||
|
||||
// Validate and get coupon if provided
|
||||
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
|
||||
var couponData = coupon != null
|
||||
? await db.WalletCoupons.FirstOrDefaultAsync(c =>
|
||||
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
|
||||
: null;
|
||||
|
||||
if (coupon != null && couponData is null)
|
||||
throw new InvalidOperationException($"Coupon {coupon} was not found.");
|
||||
|
||||
// Set defaults
|
||||
giftDuration ??= Duration.FromDays(30); // Gift expires in 30 days
|
||||
cycleDuration ??= Duration.FromDays(30); // Subscription lasts 30 days once redeemed
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Generate unique gift code
|
||||
var giftCode = await GenerateUniqueGiftCodeAsync();
|
||||
|
||||
// Calculate final price (with potential coupon discount)
|
||||
var tempSubscription = new SnWalletSubscription
|
||||
{
|
||||
BasePrice = subscriptionInfo.BasePrice,
|
||||
CouponId = couponData?.Id,
|
||||
Coupon = couponData,
|
||||
BegunAt = now // Need for price calculation
|
||||
};
|
||||
|
||||
var finalPrice = tempSubscription.CalculateFinalPriceAt(now);
|
||||
|
||||
var gift = new SnWalletGift
|
||||
{
|
||||
GifterId = gifter.Id,
|
||||
RecipientId = recipientId,
|
||||
GiftCode = giftCode,
|
||||
Message = message,
|
||||
SubscriptionIdentifier = subscriptionIdentifier,
|
||||
BasePrice = subscriptionInfo.BasePrice,
|
||||
FinalPrice = finalPrice,
|
||||
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
|
||||
ExpiresAt = now.Plus(giftDuration.Value),
|
||||
IsOpenGift = !recipientId.HasValue,
|
||||
PaymentMethod = paymentMethod,
|
||||
PaymentDetails = paymentDetails,
|
||||
CouponId = couponData?.Id,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
db.WalletGifts.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
gift.Gifter = gifter;
|
||||
|
||||
return gift;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a gift using the redemption code, creating a subscription for the redeemer.
|
||||
/// </summary>
|
||||
/// <param name="redeemer">The account redeeming the gift.</param>
|
||||
/// <param name="giftCode">The unique redemption code.</param>
|
||||
/// <returns>A tuple containing the activated gift and the created subscription.</returns>
|
||||
public async Task<(SnWalletGift Gift, SnWalletSubscription Subscription)> RedeemGiftAsync(
|
||||
SnAccount redeemer,
|
||||
string giftCode)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Find and validate the gift
|
||||
var gift = await db.WalletGifts
|
||||
.Include(g => g.Coupon) // Include coupon for price calculation
|
||||
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
|
||||
|
||||
if (gift is null)
|
||||
throw new InvalidOperationException("Gift code not found.");
|
||||
|
||||
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
|
||||
throw new InvalidOperationException("Gift is not available for redemption.");
|
||||
|
||||
if (now > gift.ExpiresAt)
|
||||
throw new InvalidOperationException("Gift has expired.");
|
||||
|
||||
if (gift.GifterId == redeemer.Id)
|
||||
throw new InvalidOperationException("You cannot redeem your own gift.");
|
||||
|
||||
// Validate redeemer permissions
|
||||
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
|
||||
throw new InvalidOperationException("This gift is not intended for you.");
|
||||
|
||||
// Check if redeemer already has this subscription type
|
||||
var subscriptionInfo = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionInfo is null)
|
||||
throw new InvalidOperationException("Invalid gift subscription type.");
|
||||
|
||||
var sameTypeSubscription = await GetSubscriptionAsync(redeemer.Id, gift.SubscriptionIdentifier);
|
||||
if (sameTypeSubscription is not null)
|
||||
{
|
||||
// Extend existing subscription
|
||||
var subscriptionDuration = Duration.FromDays(28);
|
||||
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
|
||||
{
|
||||
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
|
||||
}
|
||||
else
|
||||
{
|
||||
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
|
||||
}
|
||||
|
||||
if (sameTypeSubscription.RenewalAt.HasValue)
|
||||
{
|
||||
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
|
||||
}
|
||||
|
||||
// Update gift status and link
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
|
||||
gift.RedeemedAt = now;
|
||||
gift.RedeemerId = redeemer.Id;
|
||||
gift.SubscriptionId = sameTypeSubscription.Id;
|
||||
gift.UpdatedAt = now;
|
||||
|
||||
using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
db.WalletSubscriptions.Update(sameTypeSubscription);
|
||||
db.WalletGifts.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
await NotifyGiftRedeemed(gift, sameTypeSubscription, redeemer);
|
||||
if (gift.GifterId != redeemer.Id)
|
||||
{
|
||||
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
|
||||
if (gifter != null) await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gifter, redeemer);
|
||||
}
|
||||
|
||||
return (gift, sameTypeSubscription);
|
||||
}
|
||||
|
||||
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||
? SubscriptionTypeData.SubscriptionDict
|
||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||
.Select(s => s.Value.Identifier)
|
||||
.ToArray()
|
||||
: [gift.SubscriptionIdentifier];
|
||||
|
||||
var existingSubscription = await GetSubscriptionAsync(redeemer.Id, subscriptionsInGroup);
|
||||
if (existingSubscription is not null)
|
||||
throw new InvalidOperationException("You already have an active subscription of this type.");
|
||||
|
||||
// We do not check account level requirement, since it is a gift
|
||||
|
||||
// Create the subscription from the gift
|
||||
var cycleDuration = Duration.FromDays(28);
|
||||
var subscription = new SnWalletSubscription
|
||||
{
|
||||
BegunAt = now,
|
||||
EndedAt = now.Plus(cycleDuration),
|
||||
Identifier = gift.SubscriptionIdentifier,
|
||||
IsActive = true,
|
||||
IsFreeTrial = false,
|
||||
Status = Shared.Models.SubscriptionStatus.Active,
|
||||
PaymentMethod = "gift", // Special payment method indicating gift redemption
|
||||
PaymentDetails = new Shared.Models.SnPaymentDetails
|
||||
{
|
||||
Currency = "gift",
|
||||
OrderId = gift.Id.ToString()
|
||||
},
|
||||
BasePrice = gift.BasePrice,
|
||||
CouponId = gift.CouponId,
|
||||
Coupon = gift.Coupon,
|
||||
RenewalAt = now.Plus(cycleDuration),
|
||||
AccountId = redeemer.Id,
|
||||
};
|
||||
|
||||
// Update the gift status
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
|
||||
gift.RedeemedAt = now;
|
||||
gift.RedeemerId = redeemer.Id;
|
||||
gift.Subscription = subscription;
|
||||
gift.UpdatedAt = now;
|
||||
|
||||
// Save both gift and subscription
|
||||
using var createTransaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
db.WalletSubscriptions.Add(subscription);
|
||||
db.WalletGifts.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await createTransaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await createTransaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
// Send notification to redeemer
|
||||
await NotifyGiftRedeemed(gift, subscription, redeemer);
|
||||
|
||||
// Send notification to gifter if different from redeemer
|
||||
if (gift.GifterId != redeemer.Id)
|
||||
{
|
||||
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
|
||||
if (gifter != null)
|
||||
{
|
||||
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
|
||||
}
|
||||
}
|
||||
|
||||
return (gift, subscription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a gift by its code (for redemption checking).
|
||||
/// </summary>
|
||||
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
|
||||
{
|
||||
return await db.WalletGifts
|
||||
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Coupon)
|
||||
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves gifts purchased by a specific account.
|
||||
/// Only returns gifts that have been sent or processed (not created/unpaid ones).
|
||||
/// </summary>
|
||||
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
|
||||
{
|
||||
return await db.WalletGifts
|
||||
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Subscription)
|
||||
.Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
|
||||
{
|
||||
return await db.WalletGifts
|
||||
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
|
||||
.Include(g => g.Subscription)
|
||||
.Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
|
||||
.OrderByDescending(g => g.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a gift as sent (ready for redemption).
|
||||
/// </summary>
|
||||
public async Task<SnWalletGift> MarkGiftAsSentAsync(Guid giftId, Guid gifterId)
|
||||
{
|
||||
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
|
||||
if (gift is null)
|
||||
throw new InvalidOperationException("Gift not found or access denied.");
|
||||
|
||||
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
|
||||
throw new InvalidOperationException("Gift cannot be marked as sent.");
|
||||
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
|
||||
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return gift;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a gift before it's redeemed.
|
||||
/// </summary>
|
||||
public async Task<SnWalletGift> CancelGiftAsync(Guid giftId, Guid gifterId)
|
||||
{
|
||||
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
|
||||
if (gift is null)
|
||||
throw new InvalidOperationException("Gift not found or access denied.");
|
||||
|
||||
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created && gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
|
||||
throw new InvalidOperationException("Gift cannot be cancelled.");
|
||||
|
||||
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Cancelled;
|
||||
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return gift;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueGiftCodeAsync()
|
||||
{
|
||||
const int maxAttempts = 10;
|
||||
const int codeLength = 12;
|
||||
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
// Generate a random code
|
||||
var code = GenerateRandomCode(codeLength);
|
||||
|
||||
// Check if it already exists
|
||||
var existingGift = await db.WalletGifts.FirstOrDefaultAsync(g => g.GiftCode == code);
|
||||
if (existingGift is null)
|
||||
return code;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to generate unique gift code.");
|
||||
}
|
||||
|
||||
private static string GenerateRandomCode(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = chars[Random.Shared.Next(chars.Length)];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
|
||||
private async Task NotifyGiftRedeemed(SnWalletGift gift, SnWalletSubscription subscription, SnAccount redeemer)
|
||||
{
|
||||
Account.AccountService.SetCultureInfo(redeemer);
|
||||
|
||||
var humanReadableName =
|
||||
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
|
||||
? humanReadable
|
||||
: subscription.Identifier;
|
||||
|
||||
var notification = new PushNotification
|
||||
{
|
||||
Topic = "gifts.redeemed",
|
||||
Title = localizer["GiftRedeemedTitle"],
|
||||
Body = localizer["GiftRedeemedBody", humanReadableName],
|
||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
|
||||
{
|
||||
["gift_id"] = gift.Id.ToString(),
|
||||
["subscription_id"] = subscription.Id.ToString()
|
||||
}),
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await pusher.SendPushNotificationToUserAsync(
|
||||
new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = redeemer.Id.ToString(),
|
||||
Notification = notification
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer)
|
||||
{
|
||||
Account.AccountService.SetCultureInfo(gifter);
|
||||
|
||||
var humanReadableName =
|
||||
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
|
||||
? humanReadable
|
||||
: subscription.Identifier;
|
||||
|
||||
var notification = new PushNotification
|
||||
{
|
||||
Topic = "gifts.claimed",
|
||||
Title = localizer["GiftClaimedTitle"],
|
||||
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name ?? redeemer.Id.ToString()],
|
||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
|
||||
{
|
||||
["gift_id"] = gift.Id.ToString(),
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
["redeemer_id"] = redeemer.Id.ToString()
|
||||
}),
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await pusher.SendPushNotificationToUserAsync(
|
||||
new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = gifter.Id.ToString(),
|
||||
Notification = notification
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/wallets")]
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
@@ -39,6 +42,72 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
return Ok(wallet);
|
||||
}
|
||||
|
||||
public class WalletStats
|
||||
{
|
||||
public Instant PeriodBegin { get; set; }
|
||||
public Instant PeriodEnd { get; set; }
|
||||
public int TotalTransactions { get; set; }
|
||||
public int TotalOrders { get; set; }
|
||||
public Dictionary<string, decimal> IncomeCatgories { get; set; } = null!;
|
||||
public Dictionary<string, decimal> OutgoingCategories { get; set; } = null!;
|
||||
public decimal TotalIncome => IncomeCatgories.Values.Sum();
|
||||
public decimal TotalOutgoing => OutgoingCategories.Values.Sum();
|
||||
public decimal Sum => TotalIncome - TotalOutgoing;
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<WalletStats>> GetWalletStats([FromQuery] int period = 30)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var wallet = await ws.GetWalletAsync(currentUser.Id);
|
||||
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
|
||||
|
||||
var periodEnd = SystemClock.Instance.GetCurrentInstant();
|
||||
var periodBegin = periodEnd.Minus(Duration.FromDays(period));
|
||||
|
||||
var cacheKey = $"wallet:stats:{currentUser.Id}:{period}";
|
||||
var cached = await cache.GetAsync<WalletStats>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
var transactions = await db.PaymentTransactions
|
||||
.Where(t => (t.PayerWalletId == wallet.Id || t.PayeeWalletId == wallet.Id) &&
|
||||
t.CreatedAt >= periodBegin && t.CreatedAt <= periodEnd)
|
||||
.ToListAsync();
|
||||
|
||||
var orders = await db.PaymentOrders
|
||||
.Where(o => o.PayeeWalletId == wallet.Id &&
|
||||
o.CreatedAt >= periodBegin && o.CreatedAt <= periodEnd)
|
||||
.ToListAsync();
|
||||
|
||||
var incomeCategories = transactions
|
||||
.Where(t => t.PayeeWalletId == wallet.Id)
|
||||
.GroupBy(t => t.Type.ToString())
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
|
||||
|
||||
var outgoingCategories = transactions
|
||||
.Where(t => t.PayerWalletId == wallet.Id)
|
||||
.GroupBy(t => t.Type.ToString())
|
||||
.ToDictionary(g => g.Key, g => g.Sum(t => t.Amount));
|
||||
|
||||
var stats = new WalletStats
|
||||
{
|
||||
PeriodBegin = periodBegin,
|
||||
PeriodEnd = periodEnd,
|
||||
TotalTransactions = transactions.Count,
|
||||
TotalOrders = orders.Count,
|
||||
IncomeCatgories = incomeCategories,
|
||||
OutgoingCategories = outgoingCategories
|
||||
};
|
||||
|
||||
await cache.SetAsync(cacheKey, stats, TimeSpan.FromHours(1));
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[HttpGet("transactions")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnWalletTransaction>>> GetTransactions(
|
||||
@@ -57,10 +126,16 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
|
||||
var transactionCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = transactionCount.ToString();
|
||||
|
||||
|
||||
var transactions = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.Include(t => t.PayerWallet)
|
||||
.ThenInclude(w => w.Account)
|
||||
.ThenInclude(w => w.Profile)
|
||||
.Include(t => t.PayeeWallet)
|
||||
.ThenInclude(w => w.Account)
|
||||
.ThenInclude(w => w.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(transactions);
|
||||
@@ -73,18 +148,18 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
|
||||
var accountWallet = await db.Wallets.Where(w => w.AccountId == currentUser.Id).FirstOrDefaultAsync();
|
||||
if (accountWallet is null) return NotFound();
|
||||
|
||||
|
||||
var query = db.PaymentOrders.AsQueryable()
|
||||
.Include(o => o.Transaction)
|
||||
.Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id))
|
||||
.AsQueryable();
|
||||
|
||||
|
||||
var orderCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = orderCount.ToString();
|
||||
|
||||
|
||||
var orders = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
@@ -102,6 +177,15 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
[Required] public Guid AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class WalletTransferRequest
|
||||
{
|
||||
public string? Remark { get; set; }
|
||||
[Required] public decimal Amount { get; set; }
|
||||
[Required] public string Currency { get; set; } = null!;
|
||||
[Required] public Guid PayeeAccountId { get; set; }
|
||||
[Required] public string PinCode { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("balance")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
||||
@@ -128,4 +212,190 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("transfer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletTransaction>> Transfer([FromBody] WalletTransferRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
// Validate PIN code
|
||||
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
|
||||
return StatusCode(403, "Invalid PIN Code");
|
||||
|
||||
if (currentUser.Id == request.PayeeAccountId) return BadRequest("Cannot transfer to yourself.");
|
||||
|
||||
try
|
||||
{
|
||||
var transaction = await payment.TransferAsync(
|
||||
payerAccountId: currentUser.Id,
|
||||
payeeAccountId: request.PayeeAccountId,
|
||||
currency: request.Currency,
|
||||
amount: request.Amount
|
||||
);
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateFundRequest
|
||||
{
|
||||
[Required] public List<Guid> RecipientAccountIds { get; set; } = new();
|
||||
[Required] public string Currency { get; set; } = null!;
|
||||
[Required] public decimal TotalAmount { get; set; }
|
||||
[Required] public FundSplitType SplitType { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public int? ExpirationHours { get; set; } // Optional: hours until expiration
|
||||
[Required] public string PinCode { get; set; } = null!; // Required PIN for fund creation
|
||||
}
|
||||
|
||||
[HttpPost("funds")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletFund>> CreateFund([FromBody] CreateFundRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
// Validate PIN code
|
||||
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
|
||||
return StatusCode(403, "Invalid PIN Code");
|
||||
|
||||
try
|
||||
{
|
||||
Duration? expiration = null;
|
||||
if (request.ExpirationHours.HasValue)
|
||||
{
|
||||
expiration = Duration.FromHours(request.ExpirationHours.Value);
|
||||
}
|
||||
|
||||
var fund = await payment.CreateFundAsync(
|
||||
creatorAccountId: currentUser.Id,
|
||||
recipientAccountIds: request.RecipientAccountIds,
|
||||
currency: request.Currency,
|
||||
totalAmount: request.TotalAmount,
|
||||
splitType: request.SplitType,
|
||||
message: request.Message,
|
||||
expiration: expiration
|
||||
);
|
||||
|
||||
return Ok(fund);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("funds")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<SnWalletFund>>> GetFunds(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] FundStatus? status = null
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var query = db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Where(f => f.CreatorAccountId == currentUser.Id ||
|
||||
f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id))
|
||||
.AsQueryable();
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(f => f.Status == status.Value);
|
||||
}
|
||||
|
||||
var fundCount = await query.CountAsync();
|
||||
Response.Headers["X-Total"] = fundCount.ToString();
|
||||
|
||||
var funds = await query
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(funds);
|
||||
}
|
||||
|
||||
[HttpGet("funds/{id}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
var fund = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.ThenInclude(r => r.RecipientAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(f => f.CreatorAccount)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.FirstOrDefaultAsync(f => f.Id == id);
|
||||
|
||||
if (fund == null)
|
||||
return NotFound("Fund not found");
|
||||
|
||||
// Check if user is creator or recipient
|
||||
var isCreator = fund.CreatorAccountId == currentUser.Id;
|
||||
var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id);
|
||||
|
||||
if (!isCreator && !isRecipient)
|
||||
return Forbid("You don't have permission to view this fund");
|
||||
|
||||
return Ok(fund);
|
||||
}
|
||||
|
||||
[HttpPost("funds/{id}/receive")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var transaction = await payment.ReceiveFundAsync(
|
||||
recipientAccountId: currentUser.Id,
|
||||
fundId: id
|
||||
);
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("overview")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<WalletOverview>> GetWalletOverview(
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var overview = await payment.GetWalletOverviewAsync(
|
||||
accountId: currentUser.Id,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
);
|
||||
|
||||
return Ok(overview);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5216",
|
||||
"SiteUrl": "https://id.solian.app",
|
||||
"BaseUrl": "http://localhost:5001",
|
||||
"SiteUrl": "http://localhost:3000",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
@@ -15,10 +15,7 @@
|
||||
"Authentication": {
|
||||
"Schemes": {
|
||||
"Bearer": {
|
||||
"ValidAudiences": [
|
||||
"http://localhost:5071",
|
||||
"https://localhost:7099"
|
||||
],
|
||||
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
|
||||
"ValidIssuer": "solar-network"
|
||||
}
|
||||
}
|
||||
@@ -74,10 +71,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Pass",
|
||||
"Url": "https://localhost:7058"
|
||||
|
@@ -1,7 +1,10 @@
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Net;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
|
||||
|
||||
@@ -10,7 +13,8 @@ namespace DysonNetwork.Ring.Connection;
|
||||
[ApiController]
|
||||
public class WebSocketController(
|
||||
WebSocketService ws,
|
||||
ILogger<WebSocketContext> logger
|
||||
ILogger<WebSocketContext> logger,
|
||||
INatsConnection nats
|
||||
) : ControllerBase
|
||||
{
|
||||
[Route("/ws")]
|
||||
@@ -64,10 +68,31 @@ public class WebSocketController(
|
||||
logger.LogDebug(
|
||||
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
|
||||
|
||||
// Broadcast WebSocket connected event
|
||||
await nats.PublishAsync(
|
||||
WebSocketConnectedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new WebSocketConnectedEvent
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
IsOffline = false
|
||||
}).ToByteArray(),
|
||||
cancellationToken: cts.Token
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
|
||||
}
|
||||
catch (WebSocketException ex) when (ex.Message.Contains("The remote party closed the WebSocket connection without completing the close handshake"))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} - client closed connection without proper handshake",
|
||||
currentUser.Name,
|
||||
currentUser.Id,
|
||||
deviceId
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
@@ -80,6 +105,19 @@ public class WebSocketController(
|
||||
finally
|
||||
{
|
||||
ws.Disconnect(connectionKey);
|
||||
|
||||
// Broadcast WebSocket disconnected event
|
||||
await nats.PublishAsync(
|
||||
WebSocketDisconnectedEvent.Type,
|
||||
GrpcTypeHelper.ConvertObjectToByteString(new WebSocketDisconnectedEvent
|
||||
{
|
||||
AccountId = accountId,
|
||||
DeviceId = deviceId,
|
||||
IsOffline = !WebSocketService.GetAccountIsConnected(accountId)
|
||||
}).ToByteArray(),
|
||||
cancellationToken: cts.Token
|
||||
);
|
||||
|
||||
logger.LogDebug(
|
||||
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}"
|
||||
);
|
||||
@@ -123,4 +161,4 @@ public class WebSocketController(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -42,7 +42,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@@ -27,7 +27,7 @@ public class RingServiceGrpc(
|
||||
public override Task<Empty> PushWebSocketPacket(PushWebSocketPacketRequest request, ServerCallContext context)
|
||||
{
|
||||
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
||||
|
||||
|
||||
WebSocketService.SendPacketToAccount(Guid.Parse(request.UserId), packet);
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
@@ -36,18 +36,18 @@ public class RingServiceGrpc(
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
||||
|
||||
|
||||
foreach (var accountId in request.UserIds)
|
||||
WebSocketService.SendPacketToAccount(Guid.Parse(accountId), packet);
|
||||
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||
ServerCallContext context)
|
||||
public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDeviceRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
||||
|
||||
|
||||
websocket.SendPacketToDevice(request.DeviceId, packet);
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
@@ -56,10 +56,10 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
|
||||
ServerCallContext context)
|
||||
{
|
||||
var packet = Shared.Models.WebSocketPacket.FromProtoValue(request.Packet);
|
||||
|
||||
|
||||
foreach (var deviceId in request.DeviceIds)
|
||||
websocket.SendPacketToDevice(deviceId, packet);
|
||||
|
||||
|
||||
return Task.FromResult(new Empty());
|
||||
}
|
||||
|
||||
@@ -77,19 +77,19 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
|
||||
: [],
|
||||
AccountId = Guid.Parse(request.UserId),
|
||||
};
|
||||
|
||||
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
|
||||
if (request.Notification.IsSavable)
|
||||
await pushService.SaveNotification(notification);
|
||||
|
||||
|
||||
await queueService.EnqueuePushNotification(
|
||||
notification,
|
||||
Guid.Parse(request.UserId),
|
||||
request.Notification.IsSavable
|
||||
);
|
||||
|
||||
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
@@ -106,21 +106,21 @@ public override Task<Empty> PushWebSocketPacketToDevice(PushWebSocketPacketToDev
|
||||
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(request.Notification.Meta) ?? []
|
||||
: [],
|
||||
};
|
||||
|
||||
|
||||
if (request.Notification.ActionUri is not null)
|
||||
notification.Meta["action_uri"] = request.Notification.ActionUri;
|
||||
|
||||
var userIds = request.UserIds.Select(Guid.Parse).ToList();
|
||||
if (request.Notification.IsSavable)
|
||||
await pushService.SaveNotification(notification, userIds);
|
||||
|
||||
|
||||
var tasks = userIds
|
||||
.Select(userId => queueService.EnqueuePushNotification(
|
||||
notification,
|
||||
userId,
|
||||
request.Notification.IsSavable
|
||||
));
|
||||
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
return new Empty();
|
||||
}
|
||||
|
@@ -2,12 +2,8 @@ using System.Text.Json;
|
||||
using DysonNetwork.Ring.Email;
|
||||
using DysonNetwork.Ring.Notification;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using Google.Protobuf;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
using NATS.Net;
|
||||
|
||||
namespace DysonNetwork.Ring.Services;
|
||||
|
||||
@@ -39,29 +35,19 @@ public class QueueBackgroundService(
|
||||
private async Task RunConsumerAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Queue consumer started");
|
||||
var js = nats.CreateJetStreamContext();
|
||||
|
||||
await js.EnsureStreamCreated("pusher_events", [QueueName]);
|
||||
|
||||
var consumer = await js.CreateOrUpdateConsumerAsync(
|
||||
"pusher_events",
|
||||
new ConsumerConfig(QueueGroup), // durable consumer
|
||||
cancellationToken: stoppingToken);
|
||||
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(cancellationToken: stoppingToken))
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = GrpcTypeHelper.ConvertByteStringToObject<QueueMessage>(ByteString.CopyFrom(msg.Data));
|
||||
if (message is not null)
|
||||
{
|
||||
await ProcessMessageAsync(msg, message, stoppingToken);
|
||||
await msg.AckAsync(cancellationToken: stoppingToken);
|
||||
await ProcessMessageAsync(message, stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
||||
await msg.AckAsync(cancellationToken: stoppingToken); // Acknowledge invalid messages to avoid redelivery
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
@@ -72,12 +58,11 @@ public class QueueBackgroundService(
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in queue consumer");
|
||||
await msg.NakAsync(cancellationToken: stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ProcessMessageAsync(NatsJSMsg<byte[]> rawMsg, QueueMessage message,
|
||||
private async ValueTask ProcessMessageAsync(QueueMessage message,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
@@ -118,7 +103,7 @@ public class QueueBackgroundService(
|
||||
{
|
||||
var pushService = scope.ServiceProvider.GetRequiredService<PushService>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<QueueBackgroundService>>();
|
||||
|
||||
|
||||
var notification = JsonSerializer.Deserialize<Shared.Models.SnNotification>(message.Data);
|
||||
if (notification == null)
|
||||
{
|
||||
@@ -130,4 +115,4 @@ public class QueueBackgroundService(
|
||||
await pushService.DeliverPushNotification(notification, cancellationToken);
|
||||
logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Net;
|
||||
|
||||
namespace DysonNetwork.Ring.Services;
|
||||
|
||||
@@ -21,15 +20,14 @@ public class QueueService(INatsConnection nats)
|
||||
})
|
||||
};
|
||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||
var js = nats.CreateJetStreamContext();
|
||||
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
}
|
||||
|
||||
public async Task EnqueuePushNotification(Shared.Models.SnNotification notification, Guid userId, bool isSavable = false)
|
||||
{
|
||||
// Update the account ID in case it wasn't set
|
||||
notification.AccountId = userId;
|
||||
|
||||
|
||||
var message = new QueueMessage
|
||||
{
|
||||
Type = QueueMessageType.PushNotification,
|
||||
@@ -37,8 +35,7 @@ public class QueueService(INatsConnection nats)
|
||||
Data = JsonSerializer.Serialize(notification)
|
||||
};
|
||||
var rawMessage = GrpcTypeHelper.ConvertObjectToByteString(message).ToByteArray();
|
||||
var js = nats.CreateJetStreamContext();
|
||||
await js.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
await nats.PublishAsync(QueueBackgroundService.QueueName, rawMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +58,4 @@ public class EmailMessage
|
||||
public string ToAddress { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"Notifications": {
|
||||
"Push": {
|
||||
@@ -36,10 +36,7 @@
|
||||
"GeoIp": {
|
||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"KnownProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
],
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Ring",
|
||||
"Url": "https://localhost:7259"
|
||||
|
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@@ -316,9 +316,9 @@ public class CacheServiceRedis : ICacheService
|
||||
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
|
||||
{
|
||||
if (string.IsNullOrEmpty(group))
|
||||
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||
throw new ArgumentException("Group cannot be null or empty.", nameof(group));
|
||||
|
||||
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||
var groupKey = string.Concat(GroupKeyPrefix, group);
|
||||
var members = await _database.SetMembersAsync(groupKey);
|
||||
|
||||
return members.Select(m => m.ToString());
|
||||
@@ -396,4 +396,4 @@ public class CacheServiceRedis : ICacheService
|
||||
var result = await func();
|
||||
return (true, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DysonNetwork.Shared.Content;
|
||||
namespace DysonNetwork.Shared.Data;
|
||||
|
||||
public abstract partial class TextSanitizer
|
||||
{
|
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="Google.Api.CommonProtos" Version="2.17.0" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.32.1" />
|
||||
<PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
|
||||
<PackageReference Include="Grpc" Version="2.46.6" />
|
||||
<PackageReference Include="Grpc.AspNetCore.Server.ClientFactory" Version="2.71.0" />
|
||||
@@ -33,18 +33,23 @@
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
||||
<PackageReference Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
|
||||
<PackageReference Include="Aspire.NATS.Net" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.4.2" />
|
||||
<PackageReference Include="Aspire.StackExchange.Redis" Version="9.4.2" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.7.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.2"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Error\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@@ -118,19 +118,14 @@ public static class Extensions
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DysonNetwork.Shared.Error;
|
||||
namespace DysonNetwork.Shared.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Standardized error payload to return to clients.
|
@@ -142,6 +142,40 @@ public abstract class Leveling
|
||||
}
|
||||
}
|
||||
|
||||
public class UsernameColor
|
||||
{
|
||||
public string Type { get; set; } = "plain"; // "plain" | "gradient"
|
||||
public string? Value { get; set; } // e.g. "red" or "#ff6600"
|
||||
public string? Direction { get; set; } // e.g. "to right"
|
||||
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
|
||||
|
||||
public Proto.UsernameColor ToProtoValue()
|
||||
{
|
||||
var proto = new Proto.UsernameColor
|
||||
{
|
||||
Type = Type,
|
||||
Value = Value,
|
||||
Direction = Direction,
|
||||
};
|
||||
if (Colors is not null)
|
||||
{
|
||||
proto.Colors.AddRange(Colors);
|
||||
}
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
|
||||
{
|
||||
return new UsernameColor
|
||||
{
|
||||
Type = proto.Type,
|
||||
Value = proto.Value,
|
||||
Direction = proto.Direction,
|
||||
Colors = proto.Colors?.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -154,6 +188,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[Column(TypeName = "jsonb")] public List<ProfileLink>? Links { get; set; }
|
||||
[Column(TypeName = "jsonb")] public UsernameColor? UsernameColor { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
public Instant? LastSeenAt { get; set; }
|
||||
|
||||
@@ -209,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
AccountId = AccountId.ToString(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
ActiveBadge = ActiveBadge?.ToProtoValue(),
|
||||
UsernameColor = UsernameColor?.ToProtoValue(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
@@ -238,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
19
DysonNetwork.Shared/Models/ActivityHeatmap.cs
Normal file
19
DysonNetwork.Shared/Models/ActivityHeatmap.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
public class ActivityHeatmap
|
||||
{
|
||||
public string Unit { get; set; } = "posts";
|
||||
|
||||
public Instant PeriodStart { get; set; }
|
||||
public Instant PeriodEnd { get; set; }
|
||||
|
||||
public List<ActivityHeatmapItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ActivityHeatmapItem
|
||||
{
|
||||
public Instant Date { get; set; }
|
||||
public int Count { get; set; }
|
||||
}
|
15
DysonNetwork.Shared/Models/Autocompletion.cs
Normal file
15
DysonNetwork.Shared/Models/Autocompletion.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
public class AutocompletionRequest
|
||||
{
|
||||
[Required] public string Content { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class Autocompletion
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public string Keyword { get; set; } = null!;
|
||||
public object Data { get; set; } = null!;
|
||||
}
|
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
|
@@ -34,7 +34,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public SnDevProject Project { get; set; } = null!;
|
||||
|
||||
|
||||
[NotMapped]
|
||||
public SnDeveloper Developer => Project.Developer;
|
||||
|
||||
@@ -81,36 +81,41 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
||||
};
|
||||
}
|
||||
|
||||
public SnCustomApp FromProtoValue(Proto.CustomApp p)
|
||||
public static SnCustomApp FromProtoValue(Proto.CustomApp p)
|
||||
{
|
||||
Id = Guid.Parse(p.Id);
|
||||
Slug = p.Slug;
|
||||
Name = p.Name;
|
||||
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description;
|
||||
Status = p.Status switch
|
||||
var obj = new SnCustomApp
|
||||
{
|
||||
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
|
||||
Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
|
||||
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
|
||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||
_ => CustomAppStatus.Developing
|
||||
Id = Guid.Parse(p.Id),
|
||||
Slug = p.Slug,
|
||||
Name = p.Name,
|
||||
Description = string.IsNullOrEmpty(p.Description) ? null : p.Description,
|
||||
Status = p.Status switch
|
||||
{
|
||||
Shared.Proto.CustomAppStatus.Developing => CustomAppStatus.Developing,
|
||||
Shared.Proto.CustomAppStatus.Staging => CustomAppStatus.Staging,
|
||||
Shared.Proto.CustomAppStatus.Production => CustomAppStatus.Production,
|
||||
Shared.Proto.CustomAppStatus.Suspended => CustomAppStatus.Suspended,
|
||||
_ => CustomAppStatus.Developing
|
||||
},
|
||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId),
|
||||
CreatedAt = p.CreatedAt.ToInstant(),
|
||||
UpdatedAt = p.UpdatedAt.ToInstant(),
|
||||
};
|
||||
ProjectId = string.IsNullOrEmpty(p.ProjectId) ? Guid.Empty : Guid.Parse(p.ProjectId);
|
||||
CreatedAt = p.CreatedAt.ToInstant();
|
||||
UpdatedAt = p.UpdatedAt.ToInstant();
|
||||
if (p.Picture is not null) Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||
if (p.Background is not null) Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
|
||||
if (p.Verification is not null) Verification = SnVerificationMark.FromProtoValue(p.Verification);
|
||||
|
||||
if (p.Picture is not null) obj.Picture = SnCloudFileReferenceObject.FromProtoValue(p.Picture);
|
||||
if (p.Background is not null) obj.Background = SnCloudFileReferenceObject.FromProtoValue(p.Background);
|
||||
if (p.Verification is not null) obj.Verification = SnVerificationMark.FromProtoValue(p.Verification);
|
||||
if (p.Links is not null)
|
||||
{
|
||||
Links = new SnCustomAppLinks
|
||||
obj.Links = new SnCustomAppLinks
|
||||
{
|
||||
HomePage = string.IsNullOrEmpty(p.Links.HomePage) ? null : p.Links.HomePage,
|
||||
PrivacyPolicy = string.IsNullOrEmpty(p.Links.PrivacyPolicy) ? null : p.Links.PrivacyPolicy,
|
||||
TermsOfService = string.IsNullOrEmpty(p.Links.TermsOfService) ? null : p.Links.TermsOfService
|
||||
};
|
||||
}
|
||||
return this;
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -35,7 +35,7 @@ public class PolicyConfig
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
public List<string>? AcceptTypes { get; set; }
|
||||
public long? MaxFileSize { get; set; }
|
||||
public int RequirePrivilege { get; set; } = 0;
|
||||
public int? RequirePrivilege { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class FilePool : ModelBase, IIdentifiedResource
|
||||
@@ -47,8 +47,8 @@ public class FilePool : ModelBase, IIdentifiedResource
|
||||
[Column(TypeName = "jsonb")] public BillingConfig BillingConfig { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")] public PolicyConfig PolicyConfig { get; set; } = new();
|
||||
public bool IsHidden { get; set; } = false;
|
||||
|
||||
|
||||
public Guid? AccountId { get; set; }
|
||||
|
||||
public string ResourceIdentifier => $"file-pool/{Id}";
|
||||
}
|
||||
}
|
||||
|
@@ -123,7 +123,7 @@ public class SnPostCategorySubscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
|
||||
public Guid? CategoryId { get; set; }
|
||||
public SnPostCategory? Category { get; set; }
|
||||
public Guid? TagId { get; set; }
|
||||
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
|
||||
public Guid PostId { get; set; }
|
||||
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
[NotMapped] public SnAccount? Account { get; set; }
|
||||
}
|
||||
|
||||
public class SnPostAward : ModelBase
|
||||
@@ -176,7 +177,7 @@ public class SnPostAward : ModelBase
|
||||
public decimal Amount { get; set; }
|
||||
public PostReactionAttitude Attitude { get; set; }
|
||||
[MaxLength(4096)] public string? Message { get; set; }
|
||||
|
||||
|
||||
public Guid PostId { get; set; }
|
||||
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
|
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
@@ -30,21 +31,21 @@ public record class SubscriptionTypeData(
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
1200,
|
||||
3
|
||||
20
|
||||
),
|
||||
[SubscriptionType.Nova] = new SubscriptionTypeData(
|
||||
SubscriptionType.Nova,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
2400,
|
||||
6
|
||||
40
|
||||
),
|
||||
[SubscriptionType.Supernova] = new SubscriptionTypeData(
|
||||
SubscriptionType.Supernova,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
3600,
|
||||
9
|
||||
60
|
||||
)
|
||||
};
|
||||
|
||||
@@ -58,6 +59,186 @@ public record class SubscriptionTypeData(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a gifted subscription that can be claimed by another user.
|
||||
/// Support both direct gifts (to specific users) and open gifts (anyone can redeem via link/code).
|
||||
/// </summary>
|
||||
[Index(nameof(GiftCode))]
|
||||
[Index(nameof(GifterId))]
|
||||
[Index(nameof(RecipientId))]
|
||||
public class SnWalletGift : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The user who purchased/gave the gift.
|
||||
/// </summary>
|
||||
public Guid GifterId { get; set; }
|
||||
public SnAccount Gifter { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The intended recipient. Null for open gifts that anyone can redeem.
|
||||
/// </summary>
|
||||
public Guid? RecipientId { get; set; }
|
||||
public SnAccount? Recipient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique redemption code/link identifier for the gift.
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string GiftCode { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom message from the gifter.
|
||||
/// </summary>
|
||||
[MaxLength(1000)]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription type being gifted.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string SubscriptionIdentifier { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The original price before any discounts.
|
||||
/// </summary>
|
||||
public decimal BasePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The final price paid after discounts.
|
||||
/// </summary>
|
||||
public decimal FinalPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status of the gift.
|
||||
/// </summary>
|
||||
public GiftStatus Status { get; set; } = GiftStatus.Created;
|
||||
|
||||
/// <summary>
|
||||
/// When the gift was redeemed. Null if not yet redeemed.
|
||||
/// </summary>
|
||||
public Instant? RedeemedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user who redeemed the gift (if different from recipient).
|
||||
/// </summary>
|
||||
public Guid? RedeemerId { get; set; }
|
||||
public SnAccount? Redeemer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription created when the gift is redeemed.
|
||||
/// </summary>
|
||||
[JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
|
||||
public Guid? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the gift expires and can no longer be redeemed.
|
||||
/// </summary>
|
||||
public Instant ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this gift can be redeemed by anyone (open gift) or only the specified recipient.
|
||||
/// </summary>
|
||||
public bool IsOpenGift { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Payment method used by the gifter.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string PaymentMethod { get; set; } = null!;
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public SnPaymentDetails PaymentDetails { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Coupon used for the gift purchase.
|
||||
/// </summary>
|
||||
public Guid? CouponId { get; set; }
|
||||
public SnWalletCoupon? Coupon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the gift can still be redeemed.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsRedeemable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Status != GiftStatus.Sent) return false;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
return now <= ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the gift has expired.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public bool IsExpired
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Status == GiftStatus.Redeemed || Status == GiftStatus.Cancelled) return false;
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
return now > ExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Uncomment once protobuf files are regenerated
|
||||
/*
|
||||
public Proto.Gift ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
GifterId = GifterId.ToString(),
|
||||
RecipientId = RecipientId?.ToString(),
|
||||
GiftCode = GiftCode,
|
||||
Message = Message,
|
||||
SubscriptionIdentifier = SubscriptionIdentifier,
|
||||
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
|
||||
Status = (Proto.GiftStatus)Status,
|
||||
RedeemedAt = RedeemedAt?.ToTimestamp(),
|
||||
RedeemerId = RedeemerId?.ToString(),
|
||||
SubscriptionId = SubscriptionId?.ToString(),
|
||||
ExpiresAt = ExpiresAt.ToTimestamp(),
|
||||
IsOpenGift = IsOpenGift,
|
||||
PaymentMethod = PaymentMethod,
|
||||
PaymentDetails = PaymentDetails.ToProtoValue(),
|
||||
CouponId = CouponId?.ToString(),
|
||||
Coupon = Coupon?.ToProtoValue(),
|
||||
IsRedeemable = IsRedeemable,
|
||||
IsExpired = IsExpired,
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
|
||||
public static SnWalletGift FromProtoValue(Proto.Gift proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
GifterId = Guid.Parse(proto.GifterId),
|
||||
RecipientId = proto.HasRecipientId ? Guid.Parse(proto.RecipientId) : null,
|
||||
GiftCode = proto.GiftCode,
|
||||
Message = proto.Message,
|
||||
SubscriptionIdentifier = proto.SubscriptionIdentifier,
|
||||
BasePrice = decimal.Parse(proto.BasePrice),
|
||||
FinalPrice = decimal.Parse(proto.FinalPrice),
|
||||
Status = (GiftStatus)proto.Status,
|
||||
RedeemedAt = proto.RedeemedAt?.ToInstant(),
|
||||
RedeemerId = proto.HasRedeemerId ? Guid.Parse(proto.RedeemerId) : null,
|
||||
SubscriptionId = proto.HasSubscriptionId ? Guid.Parse(proto.SubscriptionId) : null,
|
||||
ExpiresAt = proto.ExpiresAt.ToInstant(),
|
||||
IsOpenGift = proto.IsOpenGift,
|
||||
PaymentMethod = proto.PaymentMethod,
|
||||
PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails),
|
||||
CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null,
|
||||
Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
*/
|
||||
}
|
||||
|
||||
public abstract class SubscriptionType
|
||||
{
|
||||
/// <summary>
|
||||
@@ -99,11 +280,24 @@ public enum SubscriptionStatus
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public enum GiftStatus
|
||||
{
|
||||
Created = 0,
|
||||
Sent = 1,
|
||||
Redeemed = 2,
|
||||
Expired = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subscription is for the Stellar Program in most cases.
|
||||
/// The paid subscription in another word.
|
||||
/// </summary>
|
||||
[Index(nameof(Identifier))]
|
||||
[Index(nameof(AccountId))]
|
||||
[Index(nameof(Status))]
|
||||
[Index(nameof(AccountId), nameof(Identifier))]
|
||||
[Index(nameof(AccountId), nameof(IsActive))]
|
||||
public class SnWalletSubscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -142,40 +336,50 @@ public class SnWalletSubscription : ModelBase
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// If this subscription was redeemed from a gift, this references the gift record.
|
||||
/// </summary>
|
||||
public SnWalletGift? Gift { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsActive) return false;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
if (BegunAt > now) return false;
|
||||
if (EndedAt.HasValue && now > EndedAt.Value) return false;
|
||||
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
|
||||
if (Status != SubscriptionStatus.Active) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
get => IsAvailableAt(SystemClock.Instance.GetCurrentInstant());
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public decimal FinalPrice
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsFreeTrial) return 0;
|
||||
if (Coupon == null) return BasePrice;
|
||||
get => CalculateFinalPriceAt(SystemClock.Instance.GetCurrentInstant());
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value ||
|
||||
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice;
|
||||
/// <summary>
|
||||
/// Optimized method to check availability at a specific instant (avoids repeated SystemClock calls).
|
||||
/// </summary>
|
||||
public bool IsAvailableAt(Instant currentInstant)
|
||||
{
|
||||
if (!IsActive) return false;
|
||||
if (BegunAt > currentInstant) return false;
|
||||
if (EndedAt.HasValue && currentInstant > EndedAt.Value) return false;
|
||||
if (RenewalAt.HasValue && currentInstant > RenewalAt.Value) return false;
|
||||
if (Status != SubscriptionStatus.Active) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
|
||||
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
|
||||
return BasePrice;
|
||||
}
|
||||
/// <summary>
|
||||
/// Optimized method to calculate final price at a specific instant (avoids repeated SystemClock calls).
|
||||
/// </summary>
|
||||
public decimal CalculateFinalPriceAt(Instant currentInstant)
|
||||
{
|
||||
if (IsFreeTrial) return 0;
|
||||
if (Coupon == null) return BasePrice;
|
||||
|
||||
if (Coupon.AffectedAt.HasValue && currentInstant < Coupon.AffectedAt.Value ||
|
||||
Coupon.ExpiredAt.HasValue && currentInstant > Coupon.ExpiredAt.Value) return BasePrice;
|
||||
|
||||
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
|
||||
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
|
||||
return BasePrice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,6 +388,9 @@ public class SnWalletSubscription : ModelBase
|
||||
/// </summary>
|
||||
public SnSubscriptionReferenceObject ToReference()
|
||||
{
|
||||
// Cache the current instant once to avoid multiple SystemClock calls
|
||||
var currentInstant = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return new SnSubscriptionReferenceObject
|
||||
{
|
||||
Id = Id,
|
||||
@@ -191,11 +398,11 @@ public class SnWalletSubscription : ModelBase
|
||||
BegunAt = BegunAt,
|
||||
EndedAt = EndedAt,
|
||||
IsActive = IsActive,
|
||||
IsAvailable = IsAvailable,
|
||||
IsAvailable = IsAvailableAt(currentInstant),
|
||||
IsFreeTrial = IsFreeTrial,
|
||||
Status = Status,
|
||||
BasePrice = BasePrice,
|
||||
FinalPrice = FinalPrice,
|
||||
FinalPrice = CalculateFinalPriceAt(currentInstant),
|
||||
RenewalAt = RenewalAt,
|
||||
AccountId = AccountId
|
||||
};
|
||||
@@ -263,11 +470,13 @@ public class SnSubscriptionReferenceObject : ModelBase
|
||||
public Instant? RenewalAt { get; set; }
|
||||
public Guid AccountId { get; set; }
|
||||
|
||||
private string? _displayName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable name of the subscription type if available.
|
||||
/// Gets the human-readable name of the subscription type if available (cached for performance).
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public string? DisplayName => SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
|
||||
public string? DisplayName => _displayName ??= SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
|
||||
? name
|
||||
: null;
|
||||
|
||||
@@ -281,8 +490,8 @@ public class SnSubscriptionReferenceObject : ModelBase
|
||||
IsAvailable = IsAvailable,
|
||||
IsFreeTrial = IsFreeTrial,
|
||||
Status = (Proto.SubscriptionStatus)Status,
|
||||
BasePrice = BasePrice.ToString(CultureInfo.CurrentCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.CurrentCulture),
|
||||
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
|
||||
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
|
||||
RenewalAt = RenewalAt?.ToTimestamp(),
|
||||
AccountId = AccountId.ToString(),
|
||||
DisplayName = DisplayName,
|
||||
@@ -401,4 +610,4 @@ public class SnWalletCoupon : ModelBase
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Shared.Models;
|
||||
|
||||
public class SnWallet : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
|
||||
public ICollection<SnWalletPocket> Pockets { get; set; } = new List<SnWalletPocket>();
|
||||
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccount Account { get; set; } = null!;
|
||||
|
||||
@@ -42,7 +44,7 @@ public class SnWalletPocket : ModelBase
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
|
||||
public Guid WalletId { get; set; }
|
||||
[JsonIgnore] public SnWallet Wallet { get; set; } = null!;
|
||||
|
||||
@@ -61,4 +63,97 @@ public class SnWalletPocket : ModelBase
|
||||
Amount = decimal.Parse(proto.Amount),
|
||||
WalletId = Guid.Parse(proto.WalletId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum FundSplitType
|
||||
{
|
||||
Even,
|
||||
Random
|
||||
}
|
||||
|
||||
public enum FundStatus
|
||||
{
|
||||
Created,
|
||||
PartiallyReceived,
|
||||
FullyReceived,
|
||||
Expired,
|
||||
Refunded
|
||||
}
|
||||
|
||||
public class SnWalletFund : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
public decimal TotalAmount { get; set; }
|
||||
public FundSplitType SplitType { get; set; }
|
||||
public FundStatus Status { get; set; } = FundStatus.Created;
|
||||
[MaxLength(4096)] public string? Message { get; set; }
|
||||
|
||||
// Creator
|
||||
public Guid CreatorAccountId { get; set; }
|
||||
public SnAccount CreatorAccount { get; set; } = null!;
|
||||
|
||||
// Recipients
|
||||
public ICollection<SnWalletFundRecipient> Recipients { get; set; } = new List<SnWalletFundRecipient>();
|
||||
|
||||
// Expiration
|
||||
public Instant ExpiredAt { get; set; }
|
||||
|
||||
public Proto.WalletFund ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Currency = Currency,
|
||||
TotalAmount = TotalAmount.ToString(CultureInfo.InvariantCulture),
|
||||
SplitType = (Proto.FundSplitType)SplitType,
|
||||
Status = (Proto.FundStatus)Status,
|
||||
Message = Message,
|
||||
CreatorAccountId = CreatorAccountId.ToString(),
|
||||
ExpiredAt = ExpiredAt.ToTimestamp(),
|
||||
};
|
||||
|
||||
public static SnWalletFund FromProtoValue(Proto.WalletFund proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
Currency = proto.Currency,
|
||||
TotalAmount = decimal.Parse(proto.TotalAmount),
|
||||
SplitType = (FundSplitType)proto.SplitType,
|
||||
Status = (FundStatus)proto.Status,
|
||||
Message = proto.Message,
|
||||
CreatorAccountId = Guid.Parse(proto.CreatorAccountId),
|
||||
ExpiredAt = proto.ExpiredAt.ToInstant(),
|
||||
};
|
||||
}
|
||||
|
||||
public class SnWalletFundRecipient : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid FundId { get; set; }
|
||||
[JsonIgnore] public SnWalletFund Fund { get; set; } = null!;
|
||||
|
||||
public Guid RecipientAccountId { get; set; }
|
||||
public SnAccount RecipientAccount { get; set; } = null!;
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsReceived { get; set; } = false;
|
||||
public Instant? ReceivedAt { get; set; }
|
||||
|
||||
public Proto.WalletFundRecipient ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
FundId = FundId.ToString(),
|
||||
RecipientAccountId = RecipientAccountId.ToString(),
|
||||
Amount = Amount.ToString(CultureInfo.InvariantCulture),
|
||||
IsReceived = IsReceived,
|
||||
ReceivedAt = ReceivedAt?.ToTimestamp(),
|
||||
};
|
||||
|
||||
public static SnWalletFundRecipient FromProtoValue(Proto.WalletFundRecipient proto) => new()
|
||||
{
|
||||
Id = Guid.Parse(proto.Id),
|
||||
FundId = Guid.Parse(proto.FundId),
|
||||
RecipientAccountId = Guid.Parse(proto.RecipientAccountId),
|
||||
Amount = decimal.Parse(proto.Amount),
|
||||
IsReceived = proto.IsReceived,
|
||||
ReceivedAt = proto.ReceivedAt?.ToInstant(),
|
||||
};
|
||||
}
|
||||
|
@@ -14,232 +14,240 @@ import 'wallet.proto';
|
||||
|
||||
// Account represents a user account in the system
|
||||
message Account {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string nick = 3;
|
||||
string language = 4;
|
||||
string region = 18;
|
||||
google.protobuf.Timestamp activated_at = 5;
|
||||
bool is_superuser = 6;
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string nick = 3;
|
||||
string language = 4;
|
||||
string region = 18;
|
||||
google.protobuf.Timestamp activated_at = 5;
|
||||
bool is_superuser = 6;
|
||||
|
||||
AccountProfile profile = 7;
|
||||
optional SubscriptionReferenceObject perk_subscription = 16;
|
||||
repeated AccountContact contacts = 8;
|
||||
repeated AccountBadge badges = 9;
|
||||
repeated AccountAuthFactor auth_factors = 10;
|
||||
repeated AccountConnection connections = 11;
|
||||
repeated Relationship outgoing_relationships = 12;
|
||||
repeated Relationship incoming_relationships = 13;
|
||||
AccountProfile profile = 7;
|
||||
optional SubscriptionReferenceObject perk_subscription = 16;
|
||||
repeated AccountContact contacts = 8;
|
||||
repeated AccountBadge badges = 9;
|
||||
repeated AccountAuthFactor auth_factors = 10;
|
||||
repeated AccountConnection connections = 11;
|
||||
repeated Relationship outgoing_relationships = 12;
|
||||
repeated Relationship incoming_relationships = 13;
|
||||
|
||||
google.protobuf.Timestamp created_at = 14;
|
||||
google.protobuf.Timestamp updated_at = 15;
|
||||
|
||||
google.protobuf.StringValue automated_id = 17;
|
||||
google.protobuf.Timestamp created_at = 14;
|
||||
google.protobuf.Timestamp updated_at = 15;
|
||||
|
||||
google.protobuf.StringValue automated_id = 17;
|
||||
}
|
||||
|
||||
// Enum for status attitude
|
||||
enum StatusAttitude {
|
||||
STATUS_ATTITUDE_UNSPECIFIED = 0;
|
||||
POSITIVE = 1;
|
||||
NEGATIVE = 2;
|
||||
NEUTRAL = 3;
|
||||
STATUS_ATTITUDE_UNSPECIFIED = 0;
|
||||
POSITIVE = 1;
|
||||
NEGATIVE = 2;
|
||||
NEUTRAL = 3;
|
||||
}
|
||||
|
||||
// AccountStatus represents the status of an account
|
||||
message AccountStatus {
|
||||
string id = 1;
|
||||
StatusAttitude attitude = 2;
|
||||
bool is_online = 3;
|
||||
bool is_customized = 4;
|
||||
bool is_invisible = 5;
|
||||
bool is_not_disturb = 6;
|
||||
google.protobuf.StringValue label = 7;
|
||||
google.protobuf.Timestamp cleared_at = 8;
|
||||
string account_id = 9;
|
||||
bytes meta = 10;
|
||||
string id = 1;
|
||||
StatusAttitude attitude = 2;
|
||||
bool is_online = 3;
|
||||
bool is_customized = 4;
|
||||
bool is_invisible = 5;
|
||||
bool is_not_disturb = 6;
|
||||
google.protobuf.StringValue label = 7;
|
||||
google.protobuf.Timestamp cleared_at = 8;
|
||||
string account_id = 9;
|
||||
bytes meta = 10;
|
||||
}
|
||||
|
||||
message UsernameColor {
|
||||
string type = 1;
|
||||
google.protobuf.StringValue value = 2;
|
||||
google.protobuf.StringValue direction = 3;
|
||||
repeated string colors = 4;
|
||||
}
|
||||
|
||||
// Profile contains detailed information about a user
|
||||
message AccountProfile {
|
||||
string id = 1;
|
||||
google.protobuf.StringValue first_name = 2;
|
||||
google.protobuf.StringValue middle_name = 3;
|
||||
google.protobuf.StringValue last_name = 4;
|
||||
google.protobuf.StringValue bio = 5;
|
||||
google.protobuf.StringValue gender = 6;
|
||||
google.protobuf.StringValue pronouns = 7;
|
||||
google.protobuf.StringValue time_zone = 8;
|
||||
google.protobuf.StringValue location = 9;
|
||||
google.protobuf.Timestamp birthday = 10;
|
||||
google.protobuf.Timestamp last_seen_at = 11;
|
||||
string id = 1;
|
||||
google.protobuf.StringValue first_name = 2;
|
||||
google.protobuf.StringValue middle_name = 3;
|
||||
google.protobuf.StringValue last_name = 4;
|
||||
google.protobuf.StringValue bio = 5;
|
||||
google.protobuf.StringValue gender = 6;
|
||||
google.protobuf.StringValue pronouns = 7;
|
||||
google.protobuf.StringValue time_zone = 8;
|
||||
google.protobuf.StringValue location = 9;
|
||||
google.protobuf.Timestamp birthday = 10;
|
||||
google.protobuf.Timestamp last_seen_at = 11;
|
||||
|
||||
VerificationMark verification = 12;
|
||||
BadgeReferenceObject active_badge = 13;
|
||||
VerificationMark verification = 12;
|
||||
BadgeReferenceObject active_badge = 13;
|
||||
|
||||
int32 experience = 14;
|
||||
int32 level = 15;
|
||||
double leveling_progress = 16;
|
||||
double social_credits = 17;
|
||||
int32 social_credits_level = 18;
|
||||
int32 experience = 14;
|
||||
int32 level = 15;
|
||||
double leveling_progress = 16;
|
||||
double social_credits = 17;
|
||||
int32 social_credits_level = 18;
|
||||
|
||||
CloudFile picture = 19;
|
||||
CloudFile background = 20;
|
||||
CloudFile picture = 19;
|
||||
CloudFile background = 20;
|
||||
|
||||
string account_id = 21;
|
||||
string account_id = 21;
|
||||
|
||||
google.protobuf.Timestamp created_at = 22;
|
||||
google.protobuf.Timestamp updated_at = 23;
|
||||
google.protobuf.Timestamp created_at = 22;
|
||||
google.protobuf.Timestamp updated_at = 23;
|
||||
optional UsernameColor username_color = 24;
|
||||
}
|
||||
|
||||
// AccountContact represents a contact method for an account
|
||||
message AccountContact {
|
||||
string id = 1;
|
||||
AccountContactType type = 2;
|
||||
google.protobuf.Timestamp verified_at = 3;
|
||||
bool is_primary = 4;
|
||||
string content = 5;
|
||||
string account_id = 6;
|
||||
string id = 1;
|
||||
AccountContactType type = 2;
|
||||
google.protobuf.Timestamp verified_at = 3;
|
||||
bool is_primary = 4;
|
||||
string content = 5;
|
||||
string account_id = 6;
|
||||
|
||||
google.protobuf.Timestamp created_at = 7;
|
||||
google.protobuf.Timestamp updated_at = 8;
|
||||
google.protobuf.Timestamp created_at = 7;
|
||||
google.protobuf.Timestamp updated_at = 8;
|
||||
}
|
||||
|
||||
// Enum for contact types
|
||||
enum AccountContactType {
|
||||
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
|
||||
EMAIL = 1;
|
||||
PHONE_NUMBER = 2;
|
||||
ADDRESS = 3;
|
||||
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
|
||||
EMAIL = 1;
|
||||
PHONE_NUMBER = 2;
|
||||
ADDRESS = 3;
|
||||
}
|
||||
|
||||
// AccountAuthFactor represents an authentication factor for an account
|
||||
message AccountAuthFactor {
|
||||
string id = 1;
|
||||
AccountAuthFactorType type = 2;
|
||||
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
|
||||
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
|
||||
int32 trustworthy = 5;
|
||||
google.protobuf.Timestamp enabled_at = 6;
|
||||
google.protobuf.Timestamp expired_at = 7;
|
||||
string account_id = 8;
|
||||
map<string, google.protobuf.Value> created_response = 9; // For initial setup
|
||||
string id = 1;
|
||||
AccountAuthFactorType type = 2;
|
||||
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
|
||||
map<string, google.protobuf.Value> config = 4; // Omitted from JSON serialization in original
|
||||
int32 trustworthy = 5;
|
||||
google.protobuf.Timestamp enabled_at = 6;
|
||||
google.protobuf.Timestamp expired_at = 7;
|
||||
string account_id = 8;
|
||||
map<string, google.protobuf.Value> created_response = 9; // For initial setup
|
||||
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp updated_at = 11;
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp updated_at = 11;
|
||||
}
|
||||
|
||||
// Enum for authentication factor types
|
||||
enum AccountAuthFactorType {
|
||||
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
|
||||
PASSWORD = 1;
|
||||
EMAIL_CODE = 2;
|
||||
IN_APP_CODE = 3;
|
||||
TIMED_CODE = 4;
|
||||
PIN_CODE = 5;
|
||||
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
|
||||
PASSWORD = 1;
|
||||
EMAIL_CODE = 2;
|
||||
IN_APP_CODE = 3;
|
||||
TIMED_CODE = 4;
|
||||
PIN_CODE = 5;
|
||||
}
|
||||
|
||||
// AccountBadge represents a badge associated with an account
|
||||
message AccountBadge {
|
||||
string id = 1; // Unique identifier for the badge
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
|
||||
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
|
||||
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
|
||||
string account_id = 8; // ID of the account this badge belongs to
|
||||
string id = 1; // Unique identifier for the badge
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
|
||||
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
|
||||
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
|
||||
string account_id = 8; // ID of the account this badge belongs to
|
||||
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
}
|
||||
|
||||
// AccountConnection represents a third-party connection for an account
|
||||
message AccountConnection {
|
||||
string id = 1;
|
||||
string provider = 2;
|
||||
string provided_identifier = 3;
|
||||
map<string, google.protobuf.Value> meta = 4;
|
||||
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
|
||||
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
|
||||
google.protobuf.Timestamp last_used_at = 7;
|
||||
string account_id = 8;
|
||||
string id = 1;
|
||||
string provider = 2;
|
||||
string provided_identifier = 3;
|
||||
map<string, google.protobuf.Value> meta = 4;
|
||||
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
|
||||
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
|
||||
google.protobuf.Timestamp last_used_at = 7;
|
||||
string account_id = 8;
|
||||
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
}
|
||||
|
||||
// VerificationMark represents verification status
|
||||
message VerificationMark {
|
||||
VerificationMarkType type = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string verified_by = 4;
|
||||
VerificationMarkType type = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string verified_by = 4;
|
||||
|
||||
google.protobuf.Timestamp created_at = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
google.protobuf.Timestamp created_at = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
}
|
||||
|
||||
enum VerificationMarkType {
|
||||
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
|
||||
OFFICIAL = 1;
|
||||
INDIVIDUAL = 2;
|
||||
ORGANIZATION = 3;
|
||||
GOVERNMENT = 4;
|
||||
CREATOR = 5;
|
||||
DEVELOPER = 6;
|
||||
PARODY = 7;
|
||||
VERIFICATION_MARK_TYPE_UNSPECIFIED = 0;
|
||||
OFFICIAL = 1;
|
||||
INDIVIDUAL = 2;
|
||||
ORGANIZATION = 3;
|
||||
GOVERNMENT = 4;
|
||||
CREATOR = 5;
|
||||
DEVELOPER = 6;
|
||||
PARODY = 7;
|
||||
}
|
||||
|
||||
// BadgeReferenceObject represents a reference to a badge with minimal information
|
||||
message BadgeReferenceObject {
|
||||
string id = 1; // Unique identifier for the badge
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
|
||||
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
|
||||
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
|
||||
string account_id = 8; // ID of the account this badge belongs to
|
||||
string id = 1; // Unique identifier for the badge
|
||||
string type = 2; // Type/category of the badge
|
||||
google.protobuf.StringValue label = 3; // Display name of the badge
|
||||
google.protobuf.StringValue caption = 4; // Optional description of the badge
|
||||
map<string, google.protobuf.Value> meta = 5; // Additional metadata for the badge
|
||||
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
|
||||
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
|
||||
string account_id = 8; // ID of the account this badge belongs to
|
||||
}
|
||||
|
||||
// Relationship represents a connection between two accounts
|
||||
message Relationship {
|
||||
string account_id = 1;
|
||||
string related_id = 2;
|
||||
optional Account account = 3;
|
||||
optional Account related = 4;
|
||||
int32 status = 5;
|
||||
string account_id = 1;
|
||||
string related_id = 2;
|
||||
optional Account account = 3;
|
||||
optional Account related = 4;
|
||||
int32 status = 5;
|
||||
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
google.protobuf.Timestamp updated_at = 7;
|
||||
google.protobuf.Timestamp created_at = 6;
|
||||
google.protobuf.Timestamp updated_at = 7;
|
||||
}
|
||||
|
||||
// Leveling information
|
||||
message LevelingInfo {
|
||||
int32 current_level = 1;
|
||||
int32 current_experience = 2;
|
||||
int32 next_level_experience = 3;
|
||||
int32 previous_level_experience = 4;
|
||||
double level_progress = 5;
|
||||
repeated int32 experience_per_level = 6;
|
||||
int32 current_level = 1;
|
||||
int32 current_experience = 2;
|
||||
int32 next_level_experience = 3;
|
||||
int32 previous_level_experience = 4;
|
||||
double level_progress = 5;
|
||||
repeated int32 experience_per_level = 6;
|
||||
}
|
||||
|
||||
// ActionLog represents a record of an action taken by a user
|
||||
message ActionLog {
|
||||
string id = 1; // Unique identifier for the log entry
|
||||
string action = 2; // The action that was performed, e.g., "user.login"
|
||||
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
|
||||
google.protobuf.StringValue user_agent = 4; // User agent of the client
|
||||
google.protobuf.StringValue ip_address = 5; // IP address of the client
|
||||
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
|
||||
string account_id = 7; // The account that performed the action
|
||||
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
|
||||
string id = 1; // Unique identifier for the log entry
|
||||
string action = 2; // The action that was performed, e.g., "user.login"
|
||||
map<string, google.protobuf.Value> meta = 3; // Metadata associated with the action
|
||||
google.protobuf.StringValue user_agent = 4; // User agent of the client
|
||||
google.protobuf.StringValue ip_address = 5; // IP address of the client
|
||||
google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP
|
||||
string account_id = 7; // The account that performed the action
|
||||
google.protobuf.StringValue session_id = 8; // The session in which the action was performed
|
||||
|
||||
google.protobuf.Timestamp created_at = 9; // When the action log was created
|
||||
google.protobuf.Timestamp created_at = 9; // When the action log was created
|
||||
}
|
||||
|
||||
message GetAccountStatusBatchResponse {
|
||||
repeated AccountStatus statuses = 1;
|
||||
repeated AccountStatus statuses = 1;
|
||||
}
|
||||
|
||||
// ====================================
|
||||
@@ -248,45 +256,46 @@ message GetAccountStatusBatchResponse {
|
||||
|
||||
// AccountService provides CRUD operations for user accounts and related entities
|
||||
service AccountService {
|
||||
// Account Operations
|
||||
rpc GetAccount(GetAccountRequest) returns (Account) {}
|
||||
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
|
||||
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
||||
// Account Operations
|
||||
rpc GetAccount(GetAccountRequest) returns (Account) {}
|
||||
rpc GetBotAccount(GetBotAccountRequest) returns (Account) {}
|
||||
rpc GetAccountBatch(GetAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc GetBotAccountBatch(GetBotAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc LookupAccountBatch(LookupAccountBatchRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc SearchAccount(SearchAccountRequest) returns (GetAccountBatchResponse) {}
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
|
||||
|
||||
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
|
||||
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
|
||||
rpc GetAccountStatus(GetAccountRequest) returns (AccountStatus) {}
|
||||
rpc GetAccountStatusBatch(GetAccountBatchRequest) returns (GetAccountStatusBatchResponse) {}
|
||||
|
||||
// Profile Operations
|
||||
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
|
||||
// Profile Operations
|
||||
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
|
||||
|
||||
// Contact Operations
|
||||
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
|
||||
// Contact Operations
|
||||
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
|
||||
|
||||
// Badge Operations
|
||||
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
|
||||
// Badge Operations
|
||||
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
|
||||
|
||||
// Authentication Factor Operations
|
||||
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
|
||||
// Authentication Factor Operations
|
||||
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
|
||||
|
||||
// Connection Operations
|
||||
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
|
||||
// Connection Operations
|
||||
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
|
||||
|
||||
// Relationship Operations
|
||||
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
|
||||
// Relationship Operations
|
||||
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
|
||||
|
||||
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
|
||||
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
|
||||
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
|
||||
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
|
||||
rpc GetRelationship(GetRelationshipRequest) returns (GetRelationshipResponse) {}
|
||||
rpc HasRelationship(GetRelationshipRequest) returns (google.protobuf.BoolValue) {}
|
||||
rpc ListFriends(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
|
||||
rpc ListBlocked(ListRelationshipSimpleRequest) returns (ListRelationshipSimpleResponse) {}
|
||||
}
|
||||
|
||||
// ActionLogService provides operations for action logs
|
||||
service ActionLogService {
|
||||
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
|
||||
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
|
||||
rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {}
|
||||
rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {}
|
||||
}
|
||||
|
||||
// ====================================
|
||||
@@ -295,184 +304,188 @@ service ActionLogService {
|
||||
|
||||
// ActionLog Requests/Responses
|
||||
message CreateActionLogRequest {
|
||||
string action = 1;
|
||||
map<string, google.protobuf.Value> meta = 2;
|
||||
google.protobuf.StringValue user_agent = 3;
|
||||
google.protobuf.StringValue ip_address = 4;
|
||||
google.protobuf.StringValue location = 5;
|
||||
string account_id = 6;
|
||||
google.protobuf.StringValue session_id = 7;
|
||||
string action = 1;
|
||||
map<string, google.protobuf.Value> meta = 2;
|
||||
google.protobuf.StringValue user_agent = 3;
|
||||
google.protobuf.StringValue ip_address = 4;
|
||||
google.protobuf.StringValue location = 5;
|
||||
string account_id = 6;
|
||||
google.protobuf.StringValue session_id = 7;
|
||||
}
|
||||
|
||||
message CreateActionLogResponse {
|
||||
ActionLog action_log = 1;
|
||||
ActionLog action_log = 1;
|
||||
}
|
||||
|
||||
message ListActionLogsRequest {
|
||||
string account_id = 1;
|
||||
string action = 2;
|
||||
int32 page_size = 3;
|
||||
string page_token = 4;
|
||||
string order_by = 5;
|
||||
string account_id = 1;
|
||||
string action = 2;
|
||||
int32 page_size = 3;
|
||||
string page_token = 4;
|
||||
string order_by = 5;
|
||||
}
|
||||
|
||||
message ListActionLogsResponse {
|
||||
repeated ActionLog action_logs = 1;
|
||||
string next_page_token = 2;
|
||||
int32 total_size = 3;
|
||||
repeated ActionLog action_logs = 1;
|
||||
string next_page_token = 2;
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
// Account Requests/Responses
|
||||
message GetAccountRequest {
|
||||
string id = 1; // Account ID to retrieve
|
||||
string id = 1; // Account ID to retrieve
|
||||
}
|
||||
|
||||
message GetBotAccountRequest {
|
||||
string automated_id = 1;
|
||||
string automated_id = 1;
|
||||
}
|
||||
|
||||
message GetAccountBatchRequest {
|
||||
repeated string id = 1; // Account ID to retrieve
|
||||
repeated string id = 1; // Account ID to retrieve
|
||||
}
|
||||
|
||||
message GetBotAccountBatchRequest {
|
||||
repeated string automated_id = 1;
|
||||
repeated string automated_id = 1;
|
||||
}
|
||||
|
||||
message LookupAccountBatchRequest {
|
||||
repeated string names = 1;
|
||||
repeated string names = 1;
|
||||
}
|
||||
|
||||
message SearchAccountRequest {
|
||||
string query = 1;
|
||||
}
|
||||
|
||||
message GetAccountBatchResponse {
|
||||
repeated Account accounts = 1; // List of accounts
|
||||
repeated Account accounts = 1; // List of accounts
|
||||
}
|
||||
|
||||
message CreateAccountRequest {
|
||||
string name = 1; // Required: Unique username
|
||||
string nick = 2; // Optional: Display name
|
||||
string language = 3; // Default language
|
||||
bool is_superuser = 4; // Admin flag
|
||||
AccountProfile profile = 5; // Initial profile data
|
||||
string name = 1; // Required: Unique username
|
||||
string nick = 2; // Optional: Display name
|
||||
string language = 3; // Default language
|
||||
bool is_superuser = 4; // Admin flag
|
||||
AccountProfile profile = 5; // Initial profile data
|
||||
}
|
||||
|
||||
message UpdateAccountRequest {
|
||||
string id = 1; // Account ID to update
|
||||
google.protobuf.StringValue name = 2; // New username if changing
|
||||
google.protobuf.StringValue nick = 3; // New display name
|
||||
google.protobuf.StringValue language = 4; // New language
|
||||
google.protobuf.BoolValue is_superuser = 5; // Admin status
|
||||
string id = 1; // Account ID to update
|
||||
google.protobuf.StringValue name = 2; // New username if changing
|
||||
google.protobuf.StringValue nick = 3; // New display name
|
||||
google.protobuf.StringValue language = 4; // New language
|
||||
google.protobuf.BoolValue is_superuser = 5; // Admin status
|
||||
}
|
||||
|
||||
message DeleteAccountRequest {
|
||||
string id = 1; // Account ID to delete
|
||||
bool purge = 2; // If true, permanently delete instead of soft delete
|
||||
string id = 1; // Account ID to delete
|
||||
bool purge = 2; // If true, permanently delete instead of soft delete
|
||||
}
|
||||
|
||||
message ListAccountsRequest {
|
||||
int32 page_size = 1; // Number of results per page
|
||||
string page_token = 2; // Token for pagination
|
||||
string filter = 3; // Filter expression
|
||||
string order_by = 4; // Sort order
|
||||
int32 page_size = 1; // Number of results per page
|
||||
string page_token = 2; // Token for pagination
|
||||
string filter = 3; // Filter expression
|
||||
string order_by = 4; // Sort order
|
||||
}
|
||||
|
||||
message ListAccountsResponse {
|
||||
repeated Account accounts = 1; // List of accounts
|
||||
string next_page_token = 2; // Token for next page
|
||||
int32 total_size = 3; // Total number of accounts
|
||||
repeated Account accounts = 1; // List of accounts
|
||||
string next_page_token = 2; // Token for next page
|
||||
int32 total_size = 3; // Total number of accounts
|
||||
}
|
||||
|
||||
// Profile Requests/Responses
|
||||
message GetProfileRequest {
|
||||
string account_id = 1; // Account ID to get profile for
|
||||
string account_id = 1; // Account ID to get profile for
|
||||
}
|
||||
|
||||
message UpdateProfileRequest {
|
||||
string account_id = 1; // Account ID to update profile for
|
||||
AccountProfile profile = 2; // Profile data to update
|
||||
google.protobuf.FieldMask update_mask = 3; // Fields to update
|
||||
string account_id = 1; // Account ID to update profile for
|
||||
AccountProfile profile = 2; // Profile data to update
|
||||
google.protobuf.FieldMask update_mask = 3; // Fields to update
|
||||
}
|
||||
|
||||
// Contact Requests/Responses
|
||||
message AddContactRequest {
|
||||
string account_id = 1; // Account to add contact to
|
||||
AccountContactType type = 2; // Type of contact
|
||||
string content = 3; // Contact content (email, phone, etc.)
|
||||
bool is_primary = 4; // If this should be the primary contact
|
||||
string account_id = 1; // Account to add contact to
|
||||
AccountContactType type = 2; // Type of contact
|
||||
string content = 3; // Contact content (email, phone, etc.)
|
||||
bool is_primary = 4; // If this should be the primary contact
|
||||
}
|
||||
|
||||
message ListContactsRequest {
|
||||
string account_id = 1; // Account ID to list contacts for
|
||||
AccountContactType type = 2; // Optional: filter by type
|
||||
bool verified_only = 3; // Only return verified contacts
|
||||
string account_id = 1; // Account ID to list contacts for
|
||||
AccountContactType type = 2; // Optional: filter by type
|
||||
bool verified_only = 3; // Only return verified contacts
|
||||
}
|
||||
|
||||
message ListContactsResponse {
|
||||
repeated AccountContact contacts = 1; // List of contacts
|
||||
repeated AccountContact contacts = 1; // List of contacts
|
||||
}
|
||||
|
||||
message VerifyContactRequest {
|
||||
string id = 1; // Contact ID to verify
|
||||
string account_id = 2; // Account ID (for validation)
|
||||
string code = 3; // Verification code
|
||||
string id = 1; // Contact ID to verify
|
||||
string account_id = 2; // Account ID (for validation)
|
||||
string code = 3; // Verification code
|
||||
}
|
||||
|
||||
// Badge Requests/Responses
|
||||
message ListBadgesRequest {
|
||||
string account_id = 1; // Account to list badges for
|
||||
string type = 2; // Optional: filter by type
|
||||
bool active_only = 3; // Only return active (non-expired) badges
|
||||
string account_id = 1; // Account to list badges for
|
||||
string type = 2; // Optional: filter by type
|
||||
bool active_only = 3; // Only return active (non-expired) badges
|
||||
}
|
||||
|
||||
message ListBadgesResponse {
|
||||
repeated AccountBadge badges = 1; // List of badges
|
||||
repeated AccountBadge badges = 1; // List of badges
|
||||
}
|
||||
|
||||
message ListAuthFactorsRequest {
|
||||
string account_id = 1; // Account to list factors for
|
||||
bool active_only = 2; // Only return active (non-expired) factors
|
||||
string account_id = 1; // Account to list factors for
|
||||
bool active_only = 2; // Only return active (non-expired) factors
|
||||
}
|
||||
|
||||
message ListAuthFactorsResponse {
|
||||
repeated AccountAuthFactor factors = 1; // List of auth factors
|
||||
repeated AccountAuthFactor factors = 1; // List of auth factors
|
||||
}
|
||||
|
||||
message ListConnectionsRequest {
|
||||
string account_id = 1; // Account to list connections for
|
||||
string provider = 2; // Optional: filter by provider
|
||||
string account_id = 1; // Account to list connections for
|
||||
string provider = 2; // Optional: filter by provider
|
||||
}
|
||||
|
||||
message ListConnectionsResponse {
|
||||
repeated AccountConnection connections = 1; // List of connections
|
||||
repeated AccountConnection connections = 1; // List of connections
|
||||
}
|
||||
|
||||
// Relationship Requests/Responses
|
||||
message ListRelationshipsRequest {
|
||||
string account_id = 1; // Account to list relationships for
|
||||
optional int32 status = 2; // Filter by status
|
||||
int32 page_size = 5; // Number of results per page
|
||||
string page_token = 6; // Token for pagination
|
||||
string account_id = 1; // Account to list relationships for
|
||||
optional int32 status = 2; // Filter by status
|
||||
int32 page_size = 5; // Number of results per page
|
||||
string page_token = 6; // Token for pagination
|
||||
}
|
||||
|
||||
message ListRelationshipsResponse {
|
||||
repeated Relationship relationships = 1; // List of relationships
|
||||
string next_page_token = 2; // Token for next page
|
||||
int32 total_size = 3; // Total number of relationships
|
||||
repeated Relationship relationships = 1; // List of relationships
|
||||
string next_page_token = 2; // Token for next page
|
||||
int32 total_size = 3; // Total number of relationships
|
||||
}
|
||||
|
||||
message GetRelationshipRequest {
|
||||
string account_id = 1;
|
||||
string related_id = 2;
|
||||
optional int32 status = 3;
|
||||
string account_id = 1;
|
||||
string related_id = 2;
|
||||
optional int32 status = 3;
|
||||
}
|
||||
|
||||
message GetRelationshipResponse {
|
||||
optional Relationship relationship = 1;
|
||||
optional Relationship relationship = 1;
|
||||
}
|
||||
|
||||
message ListRelationshipSimpleRequest {
|
||||
string account_id = 1;
|
||||
string account_id = 1;
|
||||
}
|
||||
|
||||
message ListRelationshipSimpleResponse {
|
||||
repeated string accounts_id = 1;
|
||||
repeated string accounts_id = 1;
|
||||
}
|
||||
|
@@ -115,8 +115,8 @@ message BotAccount {
|
||||
message CreateBotAccountRequest {
|
||||
Account account = 1;
|
||||
string automated_id = 2;
|
||||
optional string picture_id = 8;
|
||||
optional string background_id = 9;
|
||||
google.protobuf.StringValue picture_id = 8;
|
||||
google.protobuf.StringValue background_id = 9;
|
||||
}
|
||||
|
||||
message CreateBotAccountResponse {
|
||||
@@ -182,4 +182,4 @@ service BotAccountReceiverService {
|
||||
rpc RotateApiKey(GetApiKeyRequest) returns (ApiKey);
|
||||
rpc DeleteApiKey(GetApiKeyRequest) returns (DeleteApiKeyResponse);
|
||||
}
|
||||
|
||||
|
||||
|
@@ -22,6 +22,42 @@ message WalletPocket {
|
||||
string wallet_id = 4;
|
||||
}
|
||||
|
||||
enum FundSplitType {
|
||||
FUND_SPLIT_TYPE_UNSPECIFIED = 0;
|
||||
FUND_SPLIT_TYPE_EVEN = 1;
|
||||
FUND_SPLIT_TYPE_RANDOM = 2;
|
||||
}
|
||||
|
||||
enum FundStatus {
|
||||
FUND_STATUS_UNSPECIFIED = 0;
|
||||
FUND_STATUS_CREATED = 1;
|
||||
FUND_STATUS_PARTIALLY_RECEIVED = 2;
|
||||
FUND_STATUS_FULLY_RECEIVED = 3;
|
||||
FUND_STATUS_EXPIRED = 4;
|
||||
FUND_STATUS_REFUNDED = 5;
|
||||
}
|
||||
|
||||
message WalletFund {
|
||||
string id = 1;
|
||||
string currency = 2;
|
||||
string total_amount = 3;
|
||||
FundSplitType split_type = 4;
|
||||
FundStatus status = 5;
|
||||
optional string message = 6;
|
||||
string creator_account_id = 7;
|
||||
google.protobuf.Timestamp expired_at = 8;
|
||||
repeated WalletFundRecipient recipients = 9;
|
||||
}
|
||||
|
||||
message WalletFundRecipient {
|
||||
string id = 1;
|
||||
string fund_id = 2;
|
||||
string recipient_account_id = 3;
|
||||
string amount = 4;
|
||||
bool is_received = 5;
|
||||
optional google.protobuf.Timestamp received_at = 6;
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
// Using proto3 enum naming convention
|
||||
SUBSCRIPTION_STATUS_UNSPECIFIED = 0;
|
||||
@@ -31,6 +67,16 @@ enum SubscriptionStatus {
|
||||
SUBSCRIPTION_STATUS_CANCELLED = 4;
|
||||
}
|
||||
|
||||
enum GiftStatus {
|
||||
// Using proto3 enum naming convention
|
||||
GIFT_STATUS_UNSPECIFIED = 0;
|
||||
GIFT_STATUS_CREATED = 1;
|
||||
GIFT_STATUS_SENT = 2;
|
||||
GIFT_STATUS_REDEEMED = 3;
|
||||
GIFT_STATUS_EXPIRED = 4;
|
||||
GIFT_STATUS_CANCELLED = 5;
|
||||
}
|
||||
|
||||
message Subscription {
|
||||
string id = 1;
|
||||
google.protobuf.Timestamp begun_at = 2;
|
||||
@@ -93,6 +139,31 @@ message Coupon {
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
}
|
||||
|
||||
message Gift {
|
||||
string id = 1;
|
||||
string gifter_id = 2;
|
||||
optional string recipient_id = 3;
|
||||
string gift_code = 4;
|
||||
optional string message = 5;
|
||||
string subscription_identifier = 6;
|
||||
string base_price = 7;
|
||||
string final_price = 8;
|
||||
GiftStatus status = 9;
|
||||
optional google.protobuf.Timestamp redeemed_at = 10;
|
||||
optional string redeemer_id = 11;
|
||||
optional string subscription_id = 12;
|
||||
google.protobuf.Timestamp expires_at = 13;
|
||||
bool is_open_gift = 14;
|
||||
string payment_method = 15;
|
||||
PaymentDetails payment_details = 16;
|
||||
optional string coupon_id = 17;
|
||||
optional Coupon coupon = 18;
|
||||
bool is_redeemable = 19;
|
||||
bool is_expired = 20;
|
||||
google.protobuf.Timestamp created_at = 21;
|
||||
google.protobuf.Timestamp updated_at = 22;
|
||||
}
|
||||
|
||||
service WalletService {
|
||||
rpc GetWallet(GetWalletRequest) returns (Wallet);
|
||||
rpc CreateWallet(CreateWalletRequest) returns (Wallet);
|
||||
|
@@ -11,7 +11,7 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
||||
var response = await accounts.GetAccountAsync(request);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
public async Task<Account> GetBotAccount(Guid automatedId)
|
||||
{
|
||||
var request = new GetBotAccountRequest { AutomatedId = automatedId.ToString() };
|
||||
@@ -26,7 +26,14 @@ public class AccountClientHelper(AccountService.AccountServiceClient accounts)
|
||||
var response = await accounts.GetAccountBatchAsync(request);
|
||||
return response.Accounts.ToList();
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<Account>> SearchAccounts(string query)
|
||||
{
|
||||
var request = new SearchAccountRequest { Query = query };
|
||||
var response = await accounts.SearchAccountAsync(request);
|
||||
return response.Accounts.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<Account>> GetBotAccountBatch(List<Guid> automatedIds)
|
||||
{
|
||||
var request = new GetBotAccountBatchRequest();
|
||||
|
@@ -1,3 +1,4 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Shared.Stream;
|
||||
@@ -5,7 +6,16 @@ namespace DysonNetwork.Shared.Stream;
|
||||
public class AccountDeletedEvent
|
||||
{
|
||||
public static string Type => "account_deleted";
|
||||
|
||||
|
||||
public Guid AccountId { get; set; } = Guid.NewGuid();
|
||||
public Instant DeletedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
}
|
||||
}
|
||||
|
||||
public class AccountStatusUpdatedEvent
|
||||
{
|
||||
public static string Type => "account_status_updated";
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public SnAccountStatus Status { get; set; } = new();
|
||||
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
}
|
||||
|
@@ -1,3 +1,5 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Shared.Stream;
|
||||
|
||||
public class WebSocketPacketEvent
|
||||
@@ -10,3 +12,23 @@ public class WebSocketPacketEvent
|
||||
public string DeviceId { get; set; } = null!;
|
||||
public byte[] PacketBytes { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class WebSocketConnectedEvent
|
||||
{
|
||||
public static string Type => "websocket_connected";
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public string DeviceId { get; set; } = null!;
|
||||
public Instant ConnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
public bool IsOffline { get; set; } = false;
|
||||
}
|
||||
|
||||
public class WebSocketDisconnectedEvent
|
||||
{
|
||||
public static string Type => "websocket_disconnected";
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public string DeviceId { get; set; } = null!;
|
||||
public Instant DisconnectedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
|
||||
public bool IsOffline { get; set; }
|
||||
}
|
||||
|
@@ -0,0 +1,20 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Sphere.Autocompletion;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/autocomplete")]
|
||||
public class AutocompletionController(AutocompletionService aus) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> TextAutocomplete([FromBody] AutocompletionRequest request, Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
146
DysonNetwork.Sphere/Autocompletion/AutocompletionService.cs
Normal file
146
DysonNetwork.Sphere/Autocompletion/AutocompletionService.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Autocompletion;
|
||||
|
||||
public class AutocompletionService(AppDatabase db, AccountClientHelper accountsHelper)
|
||||
{
|
||||
public async Task<List<DysonNetwork.Shared.Models.Autocompletion>> GetAutocompletion(string content, Guid? chatId = null, Guid? realmId = null, int limit = 10)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return [];
|
||||
|
||||
if (content.StartsWith('@'))
|
||||
{
|
||||
var afterAt = content[1..];
|
||||
string type;
|
||||
string query;
|
||||
var hadSlash = afterAt.Contains('/');
|
||||
if (hadSlash)
|
||||
{
|
||||
var parts = afterAt.Split('/', 2);
|
||||
type = parts[0];
|
||||
query = parts.Length > 1 ? parts[1] : "";
|
||||
}
|
||||
else
|
||||
{
|
||||
type = "u";
|
||||
query = afterAt;
|
||||
}
|
||||
|
||||
return await AutocompleteAt(type, query, chatId, realmId, hadSlash, limit);
|
||||
}
|
||||
|
||||
if (!content.StartsWith(':')) return [];
|
||||
{
|
||||
var query = content[1..];
|
||||
return await AutocompleteSticker(query, limit);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteAt(string type, string query, Guid? chatId, Guid? realmId, bool hadSlash,
|
||||
int limit)
|
||||
{
|
||||
var results = new List<DysonNetwork.Shared.Models.Autocompletion>();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "u":
|
||||
var allAccounts = await accountsHelper.SearchAccounts(query);
|
||||
var filteredAccounts = allAccounts;
|
||||
|
||||
if (chatId.HasValue)
|
||||
{
|
||||
var chatMemberIds = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == chatId.Value && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
var chatMemberIdStrings = chatMemberIds.Select(id => id.ToString()).ToHashSet();
|
||||
filteredAccounts = allAccounts.Where(a => chatMemberIdStrings.Contains(a.Id)).ToList();
|
||||
}
|
||||
else if (realmId.HasValue)
|
||||
{
|
||||
var realmMemberIds = await db.RealmMembers
|
||||
.Where(m => m.RealmId == realmId.Value && m.LeaveAt == null)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
var realmMemberIdStrings = realmMemberIds.Select(id => id.ToString()).ToHashSet();
|
||||
filteredAccounts = allAccounts.Where(a => realmMemberIdStrings.Contains(a.Id)).ToList();
|
||||
}
|
||||
|
||||
var users = filteredAccounts
|
||||
.Take(limit)
|
||||
.Select(a => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "user",
|
||||
Keyword = "@" + (hadSlash ? "u/" : "") + a.Name,
|
||||
Data = SnAccount.FromProtoValue(a)
|
||||
})
|
||||
.ToList();
|
||||
results.AddRange(users);
|
||||
break;
|
||||
case "p":
|
||||
var publishers = await db.Publishers
|
||||
.Where(p => EF.Functions.Like(p.Name, $"{query}%") || EF.Functions.Like(p.Nick, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "publisher",
|
||||
Keyword = "@p/" + p.Name,
|
||||
Data = p
|
||||
})
|
||||
.ToListAsync();
|
||||
results.AddRange(publishers);
|
||||
break;
|
||||
|
||||
case "r":
|
||||
var realms = await db.Realms
|
||||
.Where(r => EF.Functions.Like(r.Slug, $"{query}%") || EF.Functions.Like(r.Name, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(r => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "realm",
|
||||
Keyword = "@r/" + r.Slug,
|
||||
Data = r
|
||||
})
|
||||
.ToListAsync();
|
||||
results.AddRange(realms);
|
||||
break;
|
||||
|
||||
case "c":
|
||||
var chats = await db.ChatRooms
|
||||
.Where(c => c.Name != null && EF.Functions.Like(c.Name, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(c => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "chat",
|
||||
Keyword = "@c/" + c.Name,
|
||||
Data = c
|
||||
})
|
||||
.ToListAsync();
|
||||
results.AddRange(chats);
|
||||
break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<List<DysonNetwork.Shared.Models.Autocompletion>> AutocompleteSticker(string query, int limit)
|
||||
{
|
||||
var stickers = await db.Stickers
|
||||
.Include(s => s.Pack)
|
||||
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(s => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "sticker",
|
||||
Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
|
||||
Data = s
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var results = stickers.ToList();
|
||||
return results;
|
||||
}
|
||||
}
|
@@ -1,9 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.RegularExpressions;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Content;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Autocompletion;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -17,7 +18,8 @@ public partial class ChatController(
|
||||
ChatService cs,
|
||||
ChatRoomService crs,
|
||||
FileService.FileServiceClient files,
|
||||
AccountService.AccountServiceClient accounts
|
||||
AccountService.AccountServiceClient accounts,
|
||||
AutocompletionService aus
|
||||
) : ControllerBase
|
||||
{
|
||||
public class MarkMessageReadRequest
|
||||
@@ -85,7 +87,8 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
@@ -101,10 +104,10 @@ public partial class ChatController(
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
var members = messages.Select(m => m.Sender).DistinctBy(x => x.Id).ToList();
|
||||
members = await crs.LoadMemberAccounts(members);
|
||||
|
||||
|
||||
foreach (var message in messages)
|
||||
message.Sender = members.First(x => x.Id == message.SenderId);
|
||||
|
||||
@@ -127,7 +130,8 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
@@ -139,16 +143,81 @@ public partial class ChatController(
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (message is null) return NotFound();
|
||||
|
||||
|
||||
message.Sender = await crs.LoadMemberAccount(message.Sender);
|
||||
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
|
||||
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
|
||||
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
|
||||
private static partial Regex MentionRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Extracts mentioned users from message content, replies, and forwards
|
||||
/// </summary>
|
||||
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
|
||||
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
|
||||
{
|
||||
var mentionedUsers = new List<Guid>();
|
||||
|
||||
// Add sender of a replied message
|
||||
if (repliedMessageId.HasValue)
|
||||
{
|
||||
var replyingTo = await db.ChatMessages
|
||||
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
|
||||
.Include(m => m.Sender)
|
||||
.Select(m => m.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
if (replyingTo != null)
|
||||
mentionedUsers.Add(replyingTo.AccountId);
|
||||
}
|
||||
|
||||
// Add sender of a forwarded message
|
||||
if (forwardedMessageId.HasValue)
|
||||
{
|
||||
var forwardedMessage = await db.ChatMessages
|
||||
.Where(m => m.Id == forwardedMessageId.Value)
|
||||
.Select(m => new { m.SenderId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (forwardedMessage != null)
|
||||
{
|
||||
mentionedUsers.Add(forwardedMessage.SenderId);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mentions from content using regex
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var mentionedNames = MentionRegex()
|
||||
.Matches(content)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (mentionedNames.Count > 0)
|
||||
{
|
||||
var queryRequest = new LookupAccountBatchRequest();
|
||||
queryRequest.Names.AddRange(mentionedNames);
|
||||
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
|
||||
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
|
||||
|
||||
if (mentionedIds.Count > 0)
|
||||
{
|
||||
var mentionedMembers = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
mentionedUsers.AddRange(mentionedMembers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mentionedUsers.Distinct().ToList();
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/messages")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "chat.messages.create")]
|
||||
@@ -186,6 +255,7 @@ public partial class ChatController(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Validate reply and forward message IDs exist
|
||||
if (request.RepliedMessageId.HasValue)
|
||||
{
|
||||
var repliedMessage = await db.ChatMessages
|
||||
@@ -206,27 +276,9 @@ public partial class ChatController(
|
||||
message.ForwardedMessageId = forwardedMessage.Id;
|
||||
}
|
||||
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var mentioned = MentionRegex()
|
||||
.Matches(request.Content)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToList();
|
||||
if (mentioned.Count > 0)
|
||||
{
|
||||
var queryRequest = new LookupAccountBatchRequest();
|
||||
queryRequest.Names.AddRange(mentioned);
|
||||
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
|
||||
var mentionedId = queryResponse
|
||||
.Select(a => Guid.Parse(a.Id))
|
||||
.ToList();
|
||||
var mentionedMembers = await db.ChatMembers
|
||||
.Where(m => mentionedId.Contains(m.AccountId))
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync();
|
||||
message.MembersMentioned = mentionedMembers;
|
||||
}
|
||||
}
|
||||
// Extract mentioned users
|
||||
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId);
|
||||
|
||||
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
|
||||
|
||||
@@ -256,6 +308,7 @@ public partial class ChatController(
|
||||
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
|
||||
return BadRequest("You cannot send an empty message.");
|
||||
|
||||
// Validate reply and forward message IDs exist
|
||||
if (request.RepliedMessageId.HasValue)
|
||||
{
|
||||
var repliedMessage = await db.ChatMessages
|
||||
@@ -272,6 +325,11 @@ public partial class ChatController(
|
||||
return BadRequest("The message you're forwarding does not exist.");
|
||||
}
|
||||
|
||||
// Update mentions based on new content and references
|
||||
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId, accountId);
|
||||
message.MembersMentioned = updatedMentions;
|
||||
|
||||
// Call service method to update the message
|
||||
await cs.UpdateMessageAsync(
|
||||
message,
|
||||
@@ -321,11 +379,30 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await db.ChatMembers
|
||||
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId);
|
||||
.AnyAsync(m =>
|
||||
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
if (!isMember)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
|
||||
var response = await cs.GetSyncDataAsync(roomId, request.LastSyncTimestamp);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/autocomplete")]
|
||||
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
|
||||
[FromBody] AutocompletionRequest request, Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await db.ChatMembers
|
||||
.AnyAsync(m =>
|
||||
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
if (!isMember)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
|
||||
var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
@@ -56,8 +56,7 @@ public class ChatRoomController(
|
||||
|
||||
var chatRooms = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.JoinedAt != null)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Include(m => m.ChatRoom)
|
||||
.Select(m => m.ChatRoom)
|
||||
.ToListAsync();
|
||||
@@ -166,7 +165,7 @@ public class ChatRoomController(
|
||||
|
||||
public class ChatRoomRequest
|
||||
{
|
||||
[Required] [MaxLength(1024)] public string? Name { get; set; }
|
||||
[Required][MaxLength(1024)] public string? Name { get; set; }
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
[MaxLength(32)] public string? PictureId { get; set; }
|
||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||
@@ -475,6 +474,7 @@ public class ChatRoomController(
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null)
|
||||
@@ -483,6 +483,37 @@ public class ChatRoomController(
|
||||
return Ok(await crs.LoadMemberAccount(member));
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}/members/online")]
|
||||
public async Task<ActionResult<int>> GetOnlineUsersCount(Guid roomId)
|
||||
{
|
||||
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||
|
||||
var room = await db.ChatRooms
|
||||
.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (!room.IsPublic)
|
||||
{
|
||||
if (currentUser is null) return Unauthorized();
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You need to be a member to see online count of private chat room.");
|
||||
}
|
||||
|
||||
var members = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
|
||||
var memberStatuses = await accountsHelper.GetAccountStatusBatch(members);
|
||||
|
||||
var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
|
||||
|
||||
return Ok(onlineCount);
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}/members")]
|
||||
public async Task<ActionResult<List<SnChatMember>>> ListMembers(Guid roomId,
|
||||
[FromQuery] int take = 20,
|
||||
@@ -500,13 +531,14 @@ public class ChatRoomController(
|
||||
{
|
||||
if (currentUser is null) return Unauthorized();
|
||||
var member = await db.ChatMembers
|
||||
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id));
|
||||
.Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
|
||||
}
|
||||
|
||||
var query = db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.LeaveAt == null);
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null);
|
||||
|
||||
if (withStatus)
|
||||
{
|
||||
@@ -603,6 +635,7 @@ public class ChatRoomController(
|
||||
var chatMember = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
|
||||
if (chatMember.Role < ChatMemberRole.Moderator)
|
||||
@@ -612,13 +645,36 @@ public class ChatRoomController(
|
||||
return StatusCode(403, "You cannot invite member with higher permission than yours.");
|
||||
}
|
||||
|
||||
var hasExistingMember = await db.ChatMembers
|
||||
var existingMember = await db.ChatMembers
|
||||
.Where(m => m.AccountId == request.RelatedUserId)
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.AnyAsync();
|
||||
if (hasExistingMember)
|
||||
return BadRequest("This user has been joined the chat cannot be invited again.");
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingMember != null)
|
||||
{
|
||||
if (existingMember.LeaveAt == null)
|
||||
return BadRequest("This user has been joined the chat cannot be invited again.");
|
||||
|
||||
existingMember.LeaveAt = null;
|
||||
existingMember.JoinedAt = null;
|
||||
db.ChatMembers.Update(existingMember);
|
||||
await db.SaveChangesAsync();
|
||||
await _SendInviteNotify(existingMember, currentUser);
|
||||
|
||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||
{
|
||||
Action = "chatrooms.invite",
|
||||
Meta =
|
||||
{
|
||||
{ "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
|
||||
{ "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
|
||||
},
|
||||
AccountId = currentUser.Id,
|
||||
UserAgent = Request.Headers.UserAgent,
|
||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
|
||||
return Ok(existingMember);
|
||||
}
|
||||
|
||||
var newMember = new SnChatMember
|
||||
{
|
||||
@@ -745,7 +801,7 @@ public class ChatRoomController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var targetMember = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (targetMember is null) return BadRequest("You have not joined this chat room.");
|
||||
if (request.NotifyLevel is not null)
|
||||
@@ -786,7 +842,7 @@ public class ChatRoomController(
|
||||
else
|
||||
{
|
||||
var targetMember = await db.ChatMembers
|
||||
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (targetMember is null) return NotFound();
|
||||
|
||||
@@ -854,7 +910,7 @@ public class ChatRoomController(
|
||||
|
||||
// Find the target member
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
@@ -899,7 +955,17 @@ public class ChatRoomController(
|
||||
var existingMember = await db.ChatMembers
|
||||
.FirstOrDefaultAsync(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId);
|
||||
if (existingMember != null)
|
||||
return BadRequest("You are already a member of this chat room.");
|
||||
{
|
||||
if (existingMember.LeaveAt == null)
|
||||
return BadRequest("You are already a member of this chat room.");
|
||||
|
||||
existingMember.LeaveAt = null;
|
||||
db.Update(existingMember);
|
||||
await db.SaveChangesAsync();
|
||||
_ = crs.PurgeRoomMembersCache(roomId);
|
||||
|
||||
return Ok(existingMember);
|
||||
}
|
||||
|
||||
var newMember = new SnChatMember
|
||||
{
|
||||
@@ -932,6 +998,7 @@ public class ChatRoomController(
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id))
|
||||
.Where(m => m.ChatRoomId == roomId)
|
||||
.FirstOrDefaultAsync();
|
||||
@@ -951,6 +1018,7 @@ public class ChatRoomController(
|
||||
}
|
||||
|
||||
member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
db.Update(member);
|
||||
await db.SaveChangesAsync();
|
||||
await crs.PurgeRoomMembersCache(roomId);
|
||||
|
||||
@@ -970,7 +1038,7 @@ public class ChatRoomController(
|
||||
{
|
||||
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
|
||||
CultureService.SetCultureInfo(account);
|
||||
|
||||
|
||||
string title = localizer["ChatInviteTitle"];
|
||||
|
||||
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
|
||||
|
@@ -45,7 +45,8 @@ public class ChatRoomService(
|
||||
if (member is not null) return member;
|
||||
|
||||
member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatRoomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
@@ -95,7 +96,7 @@ public class ChatRoomService(
|
||||
? await db.ChatMembers
|
||||
.Where(m => directRoomsId.Contains(m.ChatRoomId))
|
||||
.Where(m => m.AccountId != userId)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
// Ignored the joined at condition here to keep showing userinfo when other didn't accept the invite of DM
|
||||
.ToListAsync()
|
||||
: [];
|
||||
members = await LoadMemberAccounts(members);
|
||||
@@ -121,7 +122,7 @@ public class ChatRoomService(
|
||||
if (room.Type != ChatRoomType.DirectMessage) return room;
|
||||
var members = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == room.Id && m.AccountId != userId)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
if (members.Count <= 0) return room;
|
||||
@@ -139,7 +140,8 @@ public class ChatRoomService(
|
||||
|
||||
var maxRequiredRole = requiredRoles.Max();
|
||||
var member = await db.ChatMembers
|
||||
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == accountId);
|
||||
.Where(m => m.ChatRoomId == roomId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
return member?.Role >= maxRequiredRole;
|
||||
}
|
||||
|
||||
@@ -155,11 +157,43 @@ public class ChatRoomService(
|
||||
var accountIds = members.Select(m => m.AccountId).ToList();
|
||||
var accounts = (await accountsHelper.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a);
|
||||
|
||||
return [.. members.Select(m =>
|
||||
{
|
||||
if (accounts.TryGetValue(m.AccountId, out var account))
|
||||
m.Account = SnAccount.FromProtoValue(account);
|
||||
return m;
|
||||
})];
|
||||
return
|
||||
[
|
||||
.. members.Select(m =>
|
||||
{
|
||||
if (accounts.TryGetValue(m.AccountId, out var account))
|
||||
m.Account = SnAccount.FromProtoValue(account);
|
||||
return m;
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
private const string ChatRoomSubscribeKeyPrefix = "chatroom:subscribe:";
|
||||
|
||||
public async Task SubscribeChatRoom(SnChatMember member)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
|
||||
await cache.SetAsync(cacheKey, true, TimeSpan.FromHours(1));
|
||||
await cache.AddToGroupAsync(cacheKey, $"chatroom:subscribers:{member.ChatRoomId}");
|
||||
}
|
||||
|
||||
public async Task UnsubscribeChatRoom(SnChatMember member)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{member.ChatRoomId}:{member.Id}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task<bool> IsSubscribedChatRoom(Guid roomId, Guid memberId)
|
||||
{
|
||||
var cacheKey = $"{ChatRoomSubscribeKeyPrefix}{roomId}:{memberId}";
|
||||
var result = await cache.GetAsync<bool?>(cacheKey);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
public async Task<List<Guid>> GetSubscribedMembers(Guid roomId)
|
||||
{
|
||||
var group = $"chatroom:subscribers:{roomId}";
|
||||
var keys = await cache.GetGroupKeysAsync(group);
|
||||
return keys.Select(k => Guid.Parse(k.Split(':').Last())).ToList();
|
||||
}
|
||||
}
|
@@ -198,8 +198,6 @@ public partial class ChatService(
|
||||
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
||||
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
message.UpdatedAt = message.CreatedAt;
|
||||
|
||||
// First complete the save operation
|
||||
db.ChatMessages.Add(message);
|
||||
@@ -209,20 +207,25 @@ public partial class ChatService(
|
||||
await CreateFileReferencesForMessageAsync(message);
|
||||
|
||||
// Then start the delivery process
|
||||
var localMessage = message;
|
||||
var localSender = sender;
|
||||
var localRoom = room;
|
||||
var localLogger = logger;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeliverMessageAsync(message, sender, room);
|
||||
await DeliverMessageAsync(localMessage, localSender, localRoom);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
||||
localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
||||
}
|
||||
});
|
||||
|
||||
// Process link preview in the background to avoid delaying message sending
|
||||
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
|
||||
var localMessageForPreview = message;
|
||||
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
|
||||
|
||||
message.Sender = sender;
|
||||
message.ChatRoom = room;
|
||||
@@ -280,7 +283,16 @@ public partial class ChatService(
|
||||
|
||||
var accountsToNotify = FilterAccountsForNotification(members, message, sender);
|
||||
|
||||
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
|
||||
// Filter out subscribed users from push notifications
|
||||
var subscribedMemberIds = new List<Guid>();
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (await scopedCrs.IsSubscribedChatRoom(member.ChatRoomId, member.Id))
|
||||
subscribedMemberIds.Add(member.AccountId);
|
||||
}
|
||||
accountsToNotify = accountsToNotify.Where(a => !subscribedMemberIds.Contains(Guid.Parse(a.Id))).ToList();
|
||||
|
||||
logger.LogInformation("Trying to deliver message to {count} accounts...", accountsToNotify.Count);
|
||||
|
||||
if (accountsToNotify.Count > 0)
|
||||
{
|
||||
@@ -289,7 +301,7 @@ public partial class ChatService(
|
||||
await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
|
||||
}
|
||||
|
||||
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
|
||||
logger.LogInformation("Delivered message to {count} accounts.", accountsToNotify.Count);
|
||||
}
|
||||
|
||||
private PushNotification BuildNotification(SnChatMessage message, SnChatMember sender, SnChatRoom room, string roomSubject,
|
||||
@@ -432,7 +444,7 @@ public partial class ChatService(
|
||||
public async Task ReadChatRoomAsync(Guid roomId, Guid userId)
|
||||
{
|
||||
var sender = await db.ChatMembers
|
||||
.Where(m => m.AccountId == userId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == userId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (sender is null) throw new ArgumentException("User is not a member of the chat room.");
|
||||
|
||||
@@ -443,7 +455,7 @@ public partial class ChatService(
|
||||
public async Task<int> CountUnreadMessage(Guid userId, Guid chatRoomId)
|
||||
{
|
||||
var sender = await db.ChatMembers
|
||||
.Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId)
|
||||
.Where(m => m.AccountId == userId && m.ChatRoomId == chatRoomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => new { m.LastReadAt })
|
||||
.FirstOrDefaultAsync();
|
||||
if (sender?.LastReadAt is null) return 0;
|
||||
|
@@ -52,7 +52,7 @@ public class RealtimeCallController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
@@ -78,7 +78,7 @@ public class RealtimeCallController(
|
||||
// Check if the user is a member of the chat room
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
@@ -151,7 +151,7 @@ public class RealtimeCallController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
@@ -171,7 +171,7 @@ public class RealtimeCallController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You need to be a normal member to end a call.");
|
||||
@@ -256,4 +256,4 @@ public class CallParticipant
|
||||
/// When the participant joined the call
|
||||
/// </summary>
|
||||
public DateTime JoinedAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
@@ -16,7 +16,7 @@
|
||||
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0"/>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.1"/>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.12.3" />
|
||||
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.10" />
|
||||
<PackageReference Include="Markdig" Version="0.41.3"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7"/>
|
||||
@@ -122,7 +122,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
0
DysonNetwork.Sphere/Post/AccountHelperClient.cs
Normal file
0
DysonNetwork.Sphere/Post/AccountHelperClient.cs
Normal file
@@ -1,9 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Content;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Sphere.Poll;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.WebReader;
|
||||
@@ -23,6 +24,7 @@ public class PostController(
|
||||
AppDatabase db,
|
||||
PostService ps,
|
||||
PublisherService pub,
|
||||
AccountClientHelper accountsHelper,
|
||||
AccountService.AccountServiceClient accounts,
|
||||
ActionLogService.ActionLogServiceClient als,
|
||||
PaymentService.PaymentServiceClient payments,
|
||||
@@ -97,7 +99,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -197,7 +199,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -228,7 +230,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -271,6 +273,14 @@ public class PostController(
|
||||
.Take(take)
|
||||
.Skip(offset)
|
||||
.ToListAsync();
|
||||
|
||||
var accountsProto = await accountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
|
||||
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
|
||||
|
||||
foreach (var reaction in reactions)
|
||||
if (accounts.TryGetValue(reaction.AccountId, out var account))
|
||||
reaction.Account = account;
|
||||
|
||||
return Ok(reactions);
|
||||
}
|
||||
|
||||
@@ -283,7 +293,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -314,7 +324,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -342,7 +352,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -448,7 +458,10 @@ public class PostController(
|
||||
|
||||
if (request.RepliedPostId is not null)
|
||||
{
|
||||
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value);
|
||||
var repliedPost = await db.Posts
|
||||
.Where(p => p.Id == request.RepliedPostId.Value)
|
||||
.Include(p => p.Publisher)
|
||||
.FirstOrDefaultAsync();
|
||||
if (repliedPost is null) return BadRequest("Post replying to was not found.");
|
||||
post.RepliedPost = repliedPost;
|
||||
post.RepliedPostId = repliedPost.Id;
|
||||
@@ -456,7 +469,10 @@ public class PostController(
|
||||
|
||||
if (request.ForwardedPostId is not null)
|
||||
{
|
||||
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value);
|
||||
var forwardedPost = await db.Posts
|
||||
.Where(p => p.Id == request.ForwardedPostId.Value)
|
||||
.Include(p => p.Publisher)
|
||||
.FirstOrDefaultAsync();
|
||||
if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
|
||||
post.ForwardedPost = forwardedPost;
|
||||
post.ForwardedPostId = forwardedPost.Id;
|
||||
@@ -513,6 +529,8 @@ public class PostController(
|
||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
});
|
||||
|
||||
post.Publisher = publisher;
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
@@ -522,6 +540,9 @@ public class PostController(
|
||||
public PostReactionAttitude Attitude { get; set; }
|
||||
}
|
||||
|
||||
public static readonly List<string> ReactionsAllowedDefault =
|
||||
["thumb_up", "thumb_down", "just_okay", "cry", "confuse", "clap", "laugh", "angry", "party", "pray", "heart"];
|
||||
|
||||
[HttpPost("{id:guid}/reactions")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "posts.react")]
|
||||
@@ -531,10 +552,14 @@ public class PostController(
|
||||
|
||||
var friendsResponse =
|
||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||
|
||||
if (!ReactionsAllowedDefault.Contains(request.Symbol))
|
||||
if (currentUser.PerkSubscription is null)
|
||||
return BadRequest("You need subscription to send custom reactions");
|
||||
|
||||
var post = await db.Posts
|
||||
.Where(e => e.Id == id)
|
||||
.Include(e => e.Publisher)
|
||||
@@ -623,7 +648,7 @@ public class PostController(
|
||||
|
||||
var friendsResponse =
|
||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||
|
||||
@@ -906,4 +931,4 @@ public class PostController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ builder.ConfigureAppKestrel(builder.Configuration);
|
||||
|
||||
// Add application services
|
||||
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppServices();
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddDysonAuth();
|
||||
|
@@ -37,6 +37,14 @@ public class PublisherController(
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
[HttpGet("{name}/heatmap")]
|
||||
public async Task<ActionResult<ActivityHeatmap>> GetPublisherHeatmap(string name)
|
||||
{
|
||||
var heatmap = await ps.GetPublisherHeatmap(name);
|
||||
if (heatmap is null) return NotFound();
|
||||
return Ok(heatmap);
|
||||
}
|
||||
|
||||
[HttpGet("{name}/stats")]
|
||||
public async Task<ActionResult<PublisherService.PublisherStats>> GetPublisherStats(string name)
|
||||
@@ -693,4 +701,4 @@ public class PublisherController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -282,8 +282,9 @@ public class PublisherService(
|
||||
public int SubscribersCount { get; set; }
|
||||
}
|
||||
|
||||
private const string PublisherStatsCacheKey = "PublisherStats_{0}";
|
||||
private const string PublisherFeatureCacheKey = "PublisherFeature_{0}_{1}";
|
||||
private const string PublisherStatsCacheKey = "publisher:{0}:stats";
|
||||
private const string PublisherHeatmapCacheKey = "publisher:{0}:heatmap";
|
||||
private const string PublisherFeatureCacheKey = "publisher:{0}:feature:{1}";
|
||||
|
||||
public async Task<PublisherStats?> GetPublisherStats(string name)
|
||||
{
|
||||
@@ -325,6 +326,45 @@ public class PublisherService(
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<ActivityHeatmap?> GetPublisherHeatmap(string name)
|
||||
{
|
||||
var cacheKey = string.Format(PublisherHeatmapCacheKey, name);
|
||||
var heatmap = await cache.GetAsync<ActivityHeatmap?>(cacheKey);
|
||||
if (heatmap is not null)
|
||||
return heatmap;
|
||||
|
||||
var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name);
|
||||
if (publisher is null) return null;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var periodStart = now.Minus(Duration.FromDays(365));
|
||||
var periodEnd = now;
|
||||
|
||||
var postGroups = await db.Posts
|
||||
.Where(p => p.PublisherId == publisher.Id && p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd)
|
||||
.Select(p => p.CreatedAt.InUtc().Date)
|
||||
.GroupBy(d => d)
|
||||
.Select(g => new { Date = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
var items = postGroups.Select(p => new ActivityHeatmapItem
|
||||
{
|
||||
Date = p.Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(),
|
||||
Count = p.Count
|
||||
}).ToList();
|
||||
|
||||
heatmap = new ActivityHeatmap
|
||||
{
|
||||
Unit = "posts",
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
Items = items.OrderBy(i => i.Date).ToList()
|
||||
};
|
||||
|
||||
await cache.SetAsync(cacheKey, heatmap, TimeSpan.FromMinutes(5));
|
||||
return heatmap;
|
||||
}
|
||||
|
||||
public async Task SetFeatureFlag(Guid publisherId, string flag)
|
||||
{
|
||||
var featureFlag = await db.PublisherFeatures
|
||||
@@ -397,4 +437,4 @@ public class PublisherService(
|
||||
return m;
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -42,8 +42,7 @@ public class RealmController(
|
||||
|
||||
var members = await db.RealmMembers
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.JoinedAt != null)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Include(e => e.Realm)
|
||||
.Select(m => m.Realm)
|
||||
.ToListAsync();
|
||||
@@ -102,13 +101,37 @@ public class RealmController(
|
||||
if (!await rs.IsMemberWithRole(realm.Id, accountId, request.Role))
|
||||
return StatusCode(403, "You cannot invite member has higher permission than yours.");
|
||||
|
||||
var hasExistingMember = await db.RealmMembers
|
||||
var existingMember = await db.RealmMembers
|
||||
.Where(m => m.AccountId == Guid.Parse(relatedUser.Id))
|
||||
.Where(m => m.RealmId == realm.Id)
|
||||
.Where(m => m.LeaveAt == null)
|
||||
.AnyAsync();
|
||||
if (hasExistingMember)
|
||||
return BadRequest("This user has been joined the realm or leave cannot be invited again.");
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingMember != null)
|
||||
{
|
||||
if (existingMember.LeaveAt == null)
|
||||
return BadRequest("This user already in the realm cannot be invited again.");
|
||||
|
||||
existingMember.LeaveAt = null;
|
||||
existingMember.JoinedAt = null;
|
||||
db.RealmMembers.Update(existingMember);
|
||||
await db.SaveChangesAsync();
|
||||
await rs.SendInviteNotify(existingMember);
|
||||
|
||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||
{
|
||||
Action = "realms.members.invite",
|
||||
Meta =
|
||||
{
|
||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
||||
{ "account_id", Value.ForString(existingMember.AccountId.ToString()) },
|
||||
{ "role", Value.ForNumber(request.Role) }
|
||||
},
|
||||
AccountId = currentUser.Id,
|
||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
||||
});
|
||||
|
||||
return Ok(existingMember);
|
||||
}
|
||||
|
||||
var member = new SnRealmMember
|
||||
{
|
||||
@@ -232,7 +255,7 @@ public class RealmController(
|
||||
|
||||
var query = db.RealmMembers
|
||||
.Where(m => m.RealmId == realm.Id)
|
||||
.Where(m => m.LeaveAt == null);
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null);
|
||||
|
||||
if (withStatus)
|
||||
{
|
||||
@@ -289,6 +312,7 @@ public class RealmController(
|
||||
var member = await db.RealmMembers
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.Realm.Slug == slug)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member is null) return NotFound();
|
||||
@@ -305,7 +329,7 @@ public class RealmController(
|
||||
var member = await db.RealmMembers
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.Realm.Slug == slug)
|
||||
.Where(m => m.JoinedAt != null)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
@@ -444,7 +468,7 @@ public class RealmController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.RealmMembers
|
||||
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null)
|
||||
.Where(m => m.AccountId == accountId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null || member.Role < RealmMemberRole.Moderator)
|
||||
return StatusCode(403, "You do not have permission to update this realm.");
|
||||
@@ -558,7 +582,32 @@ public class RealmController(
|
||||
.Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.RealmId == realm.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingMember is not null)
|
||||
return BadRequest("You are already a member of this realm.");
|
||||
{
|
||||
if (existingMember.LeaveAt == null)
|
||||
return BadRequest("You are already a member of this realm.");
|
||||
|
||||
existingMember.LeaveAt = null;
|
||||
existingMember.JoinedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
db.Update(existingMember);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
_ = als.CreateActionLogAsync(new CreateActionLogRequest
|
||||
{
|
||||
Action = "realms.members.join",
|
||||
Meta =
|
||||
{
|
||||
{ "realm_id", Value.ForString(realm.Id.ToString()) },
|
||||
{ "account_id", Value.ForString(currentUser.Id) },
|
||||
{ "is_community", Value.ForBool(realm.IsCommunity) }
|
||||
},
|
||||
AccountId = currentUser.Id,
|
||||
UserAgent = Request.Headers.UserAgent.ToString(),
|
||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? ""
|
||||
});
|
||||
|
||||
return Ok(existingMember);
|
||||
}
|
||||
|
||||
var member = new SnRealmMember
|
||||
{
|
||||
@@ -600,7 +649,7 @@ public class RealmController(
|
||||
if (realm is null) return NotFound();
|
||||
|
||||
var member = await db.RealmMembers
|
||||
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
|
||||
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
@@ -640,7 +689,7 @@ public class RealmController(
|
||||
if (realm is null) return NotFound();
|
||||
|
||||
var member = await db.RealmMembers
|
||||
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id)
|
||||
.Where(m => m.AccountId == memberId && m.RealmId == realm.Id && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
|
@@ -30,6 +30,7 @@ public class RealmService(
|
||||
var realms = await db.RealmMembers
|
||||
.Include(m => m.Realm)
|
||||
.Where(m => m.AccountId == accountId)
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.Realm!.Id)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -67,7 +68,8 @@ public class RealmService(
|
||||
|
||||
var maxRequiredRole = requiredRoles.Max();
|
||||
var member = await db.RealmMembers
|
||||
.FirstOrDefaultAsync(m => m.RealmId == realmId && m.AccountId == accountId);
|
||||
.Where(m => m.RealmId == realmId && m.AccountId == accountId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
return member?.Role >= maxRequiredRole;
|
||||
}
|
||||
|
||||
@@ -90,4 +92,4 @@ public class RealmService(
|
||||
return m;
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Stream;
|
||||
using DysonNetwork.Sphere.Chat;
|
||||
using DysonNetwork.Sphere.Post;
|
||||
using Google.Protobuf;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream.Models;
|
||||
@@ -39,8 +40,9 @@ public class BroadcastEventHandler(
|
||||
var paymentTask = HandlePaymentOrders(stoppingToken);
|
||||
var accountTask = HandleAccountDeletions(stoppingToken);
|
||||
var websocketTask = HandleWebSocketPackets(stoppingToken);
|
||||
var accountStatusTask = HandleAccountStatusUpdates(stoppingToken);
|
||||
|
||||
await Task.WhenAll(paymentTask, accountTask, websocketTask);
|
||||
await Task.WhenAll(paymentTask, accountTask, websocketTask, accountStatusTask);
|
||||
}
|
||||
|
||||
private async Task HandlePaymentOrders(CancellationToken stoppingToken)
|
||||
@@ -192,6 +194,12 @@ public class BroadcastEventHandler(
|
||||
case "messages.typing":
|
||||
await HandleMessageTyping(evt, packet);
|
||||
break;
|
||||
case "messages.subscribe":
|
||||
await HandleMessageSubscribe(evt, packet);
|
||||
break;
|
||||
case "messages.unsubscribe":
|
||||
await HandleMessageUnsubscribe(evt, packet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -278,6 +286,123 @@ public class BroadcastEventHandler(
|
||||
await pusher.PushWebSocketPacketToUsersAsync(respRequest);
|
||||
}
|
||||
|
||||
private async Task HandleMessageSubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "messages.subscribe requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
await crs.SubscribeChatRoom(sender);
|
||||
}
|
||||
|
||||
private async Task HandleMessageUnsubscribe(WebSocketPacketEvent evt, WebSocketPacket packet)
|
||||
{
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var crs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
if (packet.Data == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "messages.unsubscribe requires you to provide the ChatRoomId");
|
||||
return;
|
||||
}
|
||||
|
||||
var requestData = packet.GetData<ChatController.ChatRoomWsUniversalRequest>();
|
||||
if (requestData == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "Invalid request data");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = await crs.GetRoomMember(evt.AccountId, requestData.ChatRoomId);
|
||||
if (sender == null)
|
||||
{
|
||||
await SendErrorResponse(evt, "User is not a member of the chat room.");
|
||||
return;
|
||||
}
|
||||
|
||||
await crs.UnsubscribeChatRoom(sender);
|
||||
}
|
||||
|
||||
private async Task HandleAccountStatusUpdates(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountStatusUpdatedEvent.Type, cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
var evt = GrpcTypeHelper.ConvertByteStringToObject<AccountStatusUpdatedEvent>(ByteString.CopyFrom(msg.Data));
|
||||
if (evt == null)
|
||||
continue;
|
||||
|
||||
logger.LogInformation("Account status updated: {AccountId}", evt.AccountId);
|
||||
|
||||
await using var scope = serviceProvider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
var chatRoomService = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
// Get user's joined chat rooms
|
||||
var userRooms = await db.ChatMembers
|
||||
.Where(m => m.AccountId == evt.AccountId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.ChatRoomId)
|
||||
.ToListAsync(cancellationToken: stoppingToken);
|
||||
|
||||
// Send WebSocket packet to subscribed users per room
|
||||
foreach (var roomId in userRooms)
|
||||
{
|
||||
var members = await chatRoomService.ListRoomMembers(roomId);
|
||||
var subscribedMemberIds = await chatRoomService.GetSubscribedMembers(roomId);
|
||||
var subscribedUsers = members
|
||||
.Where(m => subscribedMemberIds.Contains(m.Id))
|
||||
.Select(m => m.AccountId.ToString())
|
||||
.ToList();
|
||||
|
||||
if (subscribedUsers.Count == 0) continue;
|
||||
var packet = new WebSocketPacket
|
||||
{
|
||||
Type = "accounts.status.update",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["status"] = evt.Status,
|
||||
["chat_room_id"] = roomId
|
||||
}
|
||||
};
|
||||
|
||||
var request = new PushWebSocketPacketToUsersRequest
|
||||
{
|
||||
Packet = packet.ToProtoValue()
|
||||
};
|
||||
request.UserIds.AddRange(subscribedUsers);
|
||||
|
||||
await pusher.PushWebSocketPacketToUsersAsync(request, cancellationToken: stoppingToken);
|
||||
|
||||
logger.LogInformation("Sent status update for room {roomId} to {count} subscribed users", roomId, subscribedUsers.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing AccountStatusUpdated");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendErrorResponse(WebSocketPacketEvent evt, string message)
|
||||
{
|
||||
await pusher.PushWebSocketPacketToDeviceAsync(new PushWebSocketPacketToDeviceRequest
|
||||
|
@@ -10,27 +10,24 @@ public static class ScheduledJobsConfiguration
|
||||
{
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
|
||||
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
|
||||
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity("AppDatabaseRecycling"));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(appDatabaseRecyclingJob)
|
||||
.ForJob("AppDatabaseRecycling")
|
||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
|
||||
var postViewFlushJob = new JobKey("PostViewFlush");
|
||||
q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity(postViewFlushJob));
|
||||
q.AddJob<PostViewFlushJob>(opts => opts.WithIdentity("PostViewFlush"));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(postViewFlushJob)
|
||||
.ForJob("PostViewFlush")
|
||||
.WithIdentity("PostViewFlushTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(1)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var webFeedScraperJob = new JobKey("WebFeedScraper");
|
||||
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity(webFeedScraperJob).StoreDurably());
|
||||
q.AddJob<WebFeedScraperJob>(opts => opts.WithIdentity("WebFeedScraper").StoreDurably());
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(webFeedScraperJob)
|
||||
.ForJob("WebFeedScraper")
|
||||
.WithIdentity("WebFeedScraperTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?")
|
||||
);
|
||||
|
@@ -15,6 +15,8 @@ using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.GeoIp;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Sphere.Autocompletion;
|
||||
using DysonNetwork.Sphere.WebReader;
|
||||
using DysonNetwork.Sphere.Discovery;
|
||||
using DysonNetwork.Sphere.Poll;
|
||||
@@ -24,7 +26,7 @@ namespace DysonNetwork.Sphere.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
|
||||
@@ -39,7 +41,6 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
}).AddDataAnnotationsLocalization(options =>
|
||||
@@ -118,6 +119,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<WebFeedService>();
|
||||
services.AddScoped<DiscoveryService>();
|
||||
services.AddScoped<PollService>();
|
||||
services.AddScoped<AccountClientHelper>();
|
||||
services.AddScoped<AutocompletionService>();
|
||||
|
||||
var translationProvider = configuration["Translation:Provider"]?.ToLower();
|
||||
switch (translationProvider)
|
||||
@@ -129,4 +132,4 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -237,6 +237,22 @@ public class StickerController(
|
||||
return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
|
||||
{
|
||||
var queryable = db.Stickers
|
||||
.Include(s => s.Pack)
|
||||
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
|
||||
return Ok(stickers);
|
||||
}
|
||||
|
||||
[HttpGet("{packId:guid}/content/{id:guid}")]
|
||||
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
|
||||
{
|
||||
@@ -420,4 +436,4 @@ public class StickerController(
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -26,7 +26,7 @@ public class StickerService(
|
||||
{
|
||||
FileId = sticker.Image.Id,
|
||||
Usage = StickerFileUsageIdentifier,
|
||||
ResourceId = sticker.ResourceIdentifier
|
||||
ResourceId = sticker.ResourceIdentifier
|
||||
});
|
||||
|
||||
return sticker;
|
||||
@@ -109,9 +109,25 @@ public class StickerService(
|
||||
// If not in cache, fetch from the database
|
||||
IQueryable<SnSticker> query = db.Stickers
|
||||
.Include(e => e.Pack);
|
||||
query = Guid.TryParse(identifier, out var guid)
|
||||
? query.Where(e => e.Id == guid)
|
||||
: query.Where(e => EF.Functions.ILike(e.Pack.Prefix + e.Slug, identifier));
|
||||
|
||||
var isV2 = identifier.Contains("+");
|
||||
|
||||
var identifierParts = identifier.Split('+');
|
||||
if (identifierParts.Length < 2) isV2 = false;
|
||||
|
||||
if (isV2)
|
||||
{
|
||||
var packPart = identifierParts[0];
|
||||
var stickerPart = identifierParts[1];
|
||||
query = query.Where(e => EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = Guid.TryParse(identifier, out var guid)
|
||||
? query.Where(e => e.Id == guid)
|
||||
: query.Where(e => EF.Functions.ILike(e.Pack.Prefix + e.Slug, identifier));
|
||||
}
|
||||
|
||||
|
||||
var sticker = await query.FirstOrDefaultAsync();
|
||||
|
||||
@@ -128,4 +144,4 @@ public class StickerService(
|
||||
await cache.RemoveAsync($"sticker:lookup:{sticker.Id}");
|
||||
await cache.RemoveAsync($"sticker:lookup:{sticker.Pack.Prefix}{sticker.Slug}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -20,8 +20,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Develop", "Dys
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Control", "DysonNetwork.Control\DysonNetwork.Control.csproj", "{7FFED190-51C7-4302-A8B5-96C839463458}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.ServiceDefaults", "DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj", "{877AAD96-C257-4305-9F1C-C9D9C9BEE615}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Gateway", "DysonNetwork.Gateway\DysonNetwork.Gateway.csproj", "{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}"
|
||||
EndProject
|
||||
Global
|
||||
@@ -58,10 +56,6 @@ Global
|
||||
{7FFED190-51C7-4302-A8B5-96C839463458}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7FFED190-51C7-4302-A8B5-96C839463458}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7FFED190-51C7-4302-A8B5-96C839463458}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{877AAD96-C257-4305-9F1C-C9D9C9BEE615}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AA4D244C-6C3A-4CD0-9DA4-5CAFFBB55085}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
@@ -71,6 +71,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AHttpUtility_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003F08_003Fdd41228e_003FHttpUtility_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConfiguration_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbb55221b2bd14b31a20b0d8bdcc7ff457328_003F19_003F707d23be_003FIConfiguration_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIConnectionMultiplexer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffd5f2a75d480e8c786b15cfa0ac11aa9bf445a667ad13d25dc9db61f2cb1b_003FIConnectionMultiplexer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIDatabaseAsync_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003F93b441a9e8201c5bdfa1b1fdc8061a77b86ccbb8566d7bae85036aba8c618f7_003FIDatabaseAsync_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIEtcdClient_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F866376757aa64634b820c41d3553727886400_003Fbb_003F0fd3f8d7_003FIEtcdClient_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHtmlString_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F95cd5fa21c574d4087dec626d8227d77be00_003Ff1_003F3a8957fa_003FIHtmlString_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIHttpForwarder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F29_003F7eee2eb9_003FIHttpForwarder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
|
345
README_GIFT_SUBSCRIPTIONS.md
Normal file
345
README_GIFT_SUBSCRIPTIONS.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Gift Subscriptions API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Gift Subscriptions feature allows users to purchase subscription gifts that can be redeemed by other users, enabling social gifting and subscription sharing within the DysonNetwork platform.
|
||||
|
||||
If you use it through the gateway, the `/api` should be replaced with the `/id`
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Purchase Gifts**: Users can buy subscriptions as gifts for specific recipients or as open gifts
|
||||
- **Gift Codes**: Each gift has a unique redemption code
|
||||
- **Flexible Redemption**: Open gifts can be redeemed by anyone, while targeted gifts are recipient-specific
|
||||
- **Security**: Prevents duplicate subscriptions and enforces account level requirements
|
||||
- **Integration**: Full integration with existing subscription, coupon, and pricing systems
|
||||
- **Clean User Experience**: Unpaid gifts are hidden from users and automatically cleaned up
|
||||
- **Automatic Maintenance**: Old unpaid gifts are removed after 24 hours
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are authenticated and require a valid user session. The base path for gift endpoints is `/api/gifts`.
|
||||
|
||||
### 1. List Sent Gifts
|
||||
|
||||
Retrieve gifts you have purchased.
|
||||
|
||||
```http
|
||||
GET /api/gifts/sent?offset=0&take=20
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response**: Array of `SnWalletGift` objects
|
||||
|
||||
### 2. List Received Gifts
|
||||
|
||||
Retrieve gifts sent to you or redeemed by you (for open gifts).
|
||||
|
||||
```http
|
||||
GET /api/gifts/received?offset=0&take=20
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response**: Array of `SnWalletGift` objects
|
||||
|
||||
### 3. Get Specific Gift
|
||||
|
||||
Retrieve details for a specific gift.
|
||||
|
||||
```http
|
||||
GET /api/gifts/{giftId}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `giftId`: GUID of the gift
|
||||
|
||||
**Response**: `SnWalletGift` object
|
||||
|
||||
### 4. Check Gift Code
|
||||
|
||||
Validate if a gift code can be redeemed by the current user.
|
||||
|
||||
```http
|
||||
GET /api/gifts/check/{giftCode}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"gift_code": "ABCD1234EFGH",
|
||||
"subscription_identifier": "basic",
|
||||
"can_redeem": true,
|
||||
"error": null,
|
||||
"message": "Happy birthday!"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Purchase a Gift
|
||||
|
||||
Create and purchase a gift subscription.
|
||||
|
||||
```http
|
||||
POST /api/gifts/purchase
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"subscription_identifier": "premium",
|
||||
"recipient_id": "550e8400-e29b-41d4-a716-446655440000", // Optional: null for open gifts
|
||||
"payment_method": "in_app_wallet",
|
||||
"payment_details": {
|
||||
"currency": "irl"
|
||||
},
|
||||
"message": "Enjoy your premium subscription!", // Optional
|
||||
"coupon": "SAVE20", // Optional
|
||||
"gift_duration_days": 30, // Optional: defaults to 30
|
||||
"subscription_duration_days": 30 // Optional: defaults to 30
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: `SnWalletGift` object
|
||||
|
||||
### 6. Redeem a Gift
|
||||
|
||||
Redeem a gift code to create a subscription for yourself.
|
||||
|
||||
```http
|
||||
POST /api/gifts/redeem
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"gift_code": "ABCD1234EFGH"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"gift": { ... },
|
||||
"subscription": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Mark Gift as Sent
|
||||
|
||||
Mark a gift as sent (ready for redemption).
|
||||
|
||||
```http
|
||||
POST /api/gifts/{giftId}/send
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `giftId`: GUID of the gift to mark as sent
|
||||
|
||||
### 8. Cancel a Gift
|
||||
|
||||
Cancel a gift before it has been redeemed.
|
||||
|
||||
```http
|
||||
POST /api/gifts/{giftId}/cancel
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `giftId`: GUID of the gift to cancel
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Client Implementation
|
||||
|
||||
Here are examples showing how to integrate gift subscriptions into your client application.
|
||||
|
||||
#### Example 1: Purchase a Gift for a Specific User
|
||||
|
||||
```javascript
|
||||
async function purchaseGiftForFriend(subscriptionId, friendId, message) {
|
||||
const response = await fetch('/api/gifts/purchase', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription_identifier: subscriptionId,
|
||||
recipient_id: friendId,
|
||||
payment_method: 'in_app_wallet',
|
||||
payment_details: { currency: 'irl' },
|
||||
message: message
|
||||
})
|
||||
});
|
||||
|
||||
const gift = await response.json();
|
||||
return gift.gift_code; // Share this code with the friend
|
||||
}
|
||||
```
|
||||
|
||||
#### Example 2: Create an Open Gift
|
||||
|
||||
```javascript
|
||||
async function createOpenGift(subscriptionId) {
|
||||
const response = await fetch('/api/gifts/purchase', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription_identifier: subscriptionId,
|
||||
payment_method: 'in_app_wallet',
|
||||
payment_details: { currency: 'irl' },
|
||||
message: 'Redeem this anywhere!'
|
||||
// No recipient_id makes it an open gift
|
||||
})
|
||||
});
|
||||
|
||||
const gift = await response.json();
|
||||
// Mark as sent to make it redeemable
|
||||
await markGiftAsSent(gift.id);
|
||||
return gift;
|
||||
}
|
||||
```
|
||||
|
||||
#### Example 3: Redeem a Gift Code
|
||||
|
||||
```javascript
|
||||
async function redeemGiftCode(giftCode) {
|
||||
// First, check if the gift can be redeemed
|
||||
const checkResponse = await fetch(`/api/gifts/check/${giftCode}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const checkResult = await checkResponse.json();
|
||||
|
||||
if (!checkResult.canRedeem) {
|
||||
throw new Error(checkResult.error);
|
||||
}
|
||||
|
||||
// If valid, redeem it
|
||||
const redeemResponse = await fetch('/api/gifts/redeem', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
gift_code: giftCode
|
||||
})
|
||||
});
|
||||
|
||||
const result = await redeemResponse.json();
|
||||
return result.subscription; // The newly created subscription
|
||||
}
|
||||
```
|
||||
|
||||
#### Example 4: Display User's Gift History
|
||||
|
||||
```javascript
|
||||
async function getGiftHistory() {
|
||||
// Get gifts I sent
|
||||
const sentResponse = await fetch('/api/gifts/sent', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const sentGifts = await sentResponse.json();
|
||||
|
||||
// Get gifts I received
|
||||
const receivedResponse = await fetch('/api/gifts/received', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const receivedGifts = await receivedResponse.json();
|
||||
|
||||
return { sent: sentGifts, received: receivedGifts };
|
||||
}
|
||||
```
|
||||
|
||||
## Gift Status Lifecycle
|
||||
|
||||
Gifts follow this status lifecycle:
|
||||
|
||||
1. **Created**: Initially purchased, can be cancelled or marked as sent
|
||||
- **Note**: Gifts in "Created" status are not visible to users and are automatically cleaned up after 24 hours if unpaid
|
||||
2. **Sent**: Made available for redemption, can be cancelled
|
||||
3. **Redeemed**: Successfully redeemed, creates a subscription
|
||||
4. **Cancelled**: Permanently cancelled, refund may be processed
|
||||
5. **Expired**: Expired without redemption
|
||||
|
||||
## Automatic Maintenance
|
||||
|
||||
The system includes automatic cleanup to maintain data integrity:
|
||||
|
||||
- **Unpaid Gift Cleanup**: Gifts that remain in "Created" status (unpaid) for more than 24 hours are automatically removed from the database
|
||||
- **User Visibility**: Only gifts that have been successfully paid and sent are visible in user gift lists
|
||||
- **Background Processing**: Cleanup runs hourly via scheduled jobs
|
||||
|
||||
This ensures a clean user experience while preventing accumulation of abandoned gift purchases.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Purchase Validation
|
||||
- Subscription must exist and be valid
|
||||
- If coupon provided, it must be valid and applicable
|
||||
- Recipient account must exist (if specified)
|
||||
- User must meet level requirements for the subscription
|
||||
|
||||
### Redemption Validation
|
||||
- Gift code must exist
|
||||
- Gift must be in "Sent" status
|
||||
- Gift must not be expired
|
||||
- User must meet level requirements
|
||||
- User must not already have an active subscription of the same type
|
||||
- For targeted gifts, user must be the specified recipient
|
||||
|
||||
## Pricing & Payments
|
||||
|
||||
Gifts use the same pricing system as regular subscriptions:
|
||||
|
||||
- Base price from subscription template
|
||||
- Coupon discounts applied
|
||||
- Currency conversion as needed
|
||||
- Payment processing through existing payment methods
|
||||
|
||||
## Notification Events
|
||||
|
||||
The system sends push notifications for:
|
||||
|
||||
- **gifts.redeemed**: When someone redeems your gift
|
||||
- **gifts.claimed**: When the recipient redeems your targeted gift
|
||||
|
||||
Notifications include gift and subscription details for rich UI updates.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Common error responses:
|
||||
|
||||
- `400 Bad Request`: Invalid parameters, validation failures
|
||||
- `401 Unauthorized`: Missing or invalid authentication
|
||||
- `403 Forbidden`: Insufficient permissions
|
||||
- `404 Not Found`: Gift or subscription not found
|
||||
- `409 Conflict`: Business logic violations (duplicate subscriptions, etc.)
|
||||
|
||||
## Integration Notes
|
||||
|
||||
### Database Schema
|
||||
The feature adds a `wallet_gifts` table with relationships to:
|
||||
- `accounts` (gifter, recipient, redeemer)
|
||||
- `wallet_subscriptions` (created subscription)
|
||||
- `wallet_coupons` (applied discounts)
|
||||
|
||||
### Backwards Compatibility
|
||||
- No changes to existing subscription endpoints
|
||||
- New gift-related endpoints are additive
|
||||
- Existing payment flows remain unchanged
|
||||
|
||||
### Performance Considerations
|
||||
- Gift codes are indexed for fast lookups
|
||||
- Status filters optimize database queries
|
||||
- Caching integrated with existing subscription caching
|
||||
|
||||
## Support
|
||||
|
||||
For implementation questions or issues, refer to the DysonNetwork API documentation or contact the development team.
|
429
README_WALLET_FUNDS.md
Normal file
429
README_WALLET_FUNDS.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Wallet Funds (Red Packet) System
|
||||
|
||||
## Overview
|
||||
|
||||
The Wallet Funds system implements red packet functionality for the DysonNetwork platform, allowing users to create funds that can be split among multiple recipients. Recipients must explicitly claim their portion, and unclaimed funds are automatically refunded after expiration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Red Packet Creation**: Users can create funds with total amounts to be distributed
|
||||
- **Split Types**: Even distribution or random (lucky draw) splitting
|
||||
- **Claim System**: Recipients must actively claim their portion
|
||||
- **Expiration**: Automatic refund of unclaimed funds after 24 hours
|
||||
- **Multi-Recipient**: Support for distributing to multiple users simultaneously
|
||||
- **Audit Trail**: Full transaction history and status tracking
|
||||
|
||||
## Architecture
|
||||
|
||||
### Models
|
||||
|
||||
#### SnWalletFund
|
||||
```csharp
|
||||
public class SnWalletFund : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
public FundSplitType SplitType { get; set; }
|
||||
public FundStatus Status { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public Guid CreatorAccountId { get; set; }
|
||||
public SnAccount CreatorAccount { get; set; }
|
||||
public ICollection<SnWalletFundRecipient> Recipients { get; set; }
|
||||
public Instant ExpiredAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### SnWalletFundRecipient
|
||||
```csharp
|
||||
public class SnWalletFundRecipient : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid FundId { get; set; }
|
||||
public SnWalletFund Fund { get; set; }
|
||||
public Guid RecipientAccountId { get; set; }
|
||||
public SnAccount RecipientAccount { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsReceived { get; set; }
|
||||
public Instant? ReceivedAt { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Enums
|
||||
|
||||
#### FundSplitType
|
||||
- `Even`: Equal distribution among all recipients
|
||||
- `Random`: Random amounts that sum to total
|
||||
|
||||
#### FundStatus
|
||||
- `Created`: Fund created, waiting for claims
|
||||
- `PartiallyReceived`: Some recipients have claimed
|
||||
- `FullyReceived`: All recipients have claimed
|
||||
- `Expired`: Fund expired, unclaimed amounts refunded
|
||||
- `Refunded`: Fund was refunded (legacy status)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Create Fund
|
||||
**POST** `/api/wallets/funds`
|
||||
|
||||
Creates a new fund (red packet) for distribution among recipients.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"recipientAccountIds": ["uuid1", "uuid2", "uuid3"],
|
||||
"currency": "points",
|
||||
"totalAmount": 100.00,
|
||||
"splitType": "Even",
|
||||
"message": "Happy Birthday! 🎉",
|
||||
"expirationHours": 48
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `SnWalletFund` object
|
||||
|
||||
**Authorization:** Required (authenticated user becomes the creator)
|
||||
|
||||
---
|
||||
|
||||
### Get Funds
|
||||
**GET** `/api/wallets/funds`
|
||||
|
||||
Retrieves funds that the authenticated user is involved in (as creator or recipient).
|
||||
|
||||
**Query Parameters:**
|
||||
- `offset` (int, optional): Pagination offset (default: 0)
|
||||
- `take` (int, optional): Number of items to return (default: 20)
|
||||
- `status` (FundStatus, optional): Filter by fund status
|
||||
|
||||
**Response:** Array of `SnWalletFund` objects with `X-Total` header
|
||||
|
||||
**Authorization:** Required
|
||||
|
||||
---
|
||||
|
||||
### Get Fund
|
||||
**GET** `/api/wallets/funds/{id}`
|
||||
|
||||
Retrieves details of a specific fund.
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (Guid): Fund ID
|
||||
|
||||
**Response:** `SnWalletFund` object with recipients
|
||||
|
||||
**Authorization:** Required (user must be creator or recipient)
|
||||
|
||||
---
|
||||
|
||||
### Receive Fund
|
||||
**POST** `/api/wallets/funds/{id}/receive`
|
||||
|
||||
Claims the authenticated user's portion of a fund.
|
||||
|
||||
**Path Parameters:**
|
||||
- `id` (Guid): Fund ID
|
||||
|
||||
**Response:** `SnWalletTransaction` object
|
||||
|
||||
**Authorization:** Required (user must be a recipient)
|
||||
|
||||
## Service Methods
|
||||
|
||||
### Creating a Fund
|
||||
|
||||
```csharp
|
||||
// Service method
|
||||
public async Task<SnWalletFund> CreateFundAsync(
|
||||
Guid creatorAccountId,
|
||||
List<Guid> recipientAccountIds,
|
||||
string currency,
|
||||
decimal totalAmount,
|
||||
FundSplitType splitType,
|
||||
string? message = null,
|
||||
Duration? expiration = null)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `creatorAccountId`: Account ID of the fund creator
|
||||
- `recipientAccountIds`: List of recipient account IDs
|
||||
- `currency`: Currency type (e.g., "points", "golds")
|
||||
- `totalAmount`: Total amount to distribute
|
||||
- `splitType`: How to split the amount (Even/Random)
|
||||
- `message`: Optional message for the fund
|
||||
- `expiration`: Optional expiration duration (default: 24 hours)
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
creatorId: userId,
|
||||
recipientAccountIds: new List<Guid> { friend1Id, friend2Id, friend3Id },
|
||||
currency: "points",
|
||||
totalAmount: 100.00m,
|
||||
splitType: FundSplitType.Even,
|
||||
message: "Happy New Year!",
|
||||
expiration: Duration.FromHours(48) // Optional: 48 hours instead of default 24
|
||||
);
|
||||
```
|
||||
|
||||
### Claiming a Fund
|
||||
|
||||
```csharp
|
||||
// Service method
|
||||
public async Task<SnWalletTransaction> ReceiveFundAsync(
|
||||
Guid recipientAccountId,
|
||||
Guid fundId)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `recipientAccountId`: Account ID of the recipient claiming the fund
|
||||
- `fundId`: ID of the fund to claim from
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
var transaction = await paymentService.ReceiveFundAsync(
|
||||
recipientAccountId: myAccountId,
|
||||
fundId: fundId
|
||||
);
|
||||
```
|
||||
|
||||
## Split Logic
|
||||
|
||||
### Even Split
|
||||
Distributes the total amount equally among all recipients, handling decimal precision properly:
|
||||
|
||||
```csharp
|
||||
private List<decimal> SplitEvenly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var baseAmount = Math.Floor(totalAmount / recipientCount * 100) / 100;
|
||||
var remainder = totalAmount - (baseAmount * recipientCount);
|
||||
|
||||
var amounts = new List<decimal>();
|
||||
for (int i = 0; i < recipientCount; i++)
|
||||
{
|
||||
var amount = baseAmount;
|
||||
if (i < remainder * 100)
|
||||
amount += 0.01m; // Distribute remainder as 0.01 increments
|
||||
amounts.Add(amount);
|
||||
}
|
||||
return amounts;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** 100.00 split among 3 recipients = [33.34, 33.33, 33.33]
|
||||
|
||||
### Random Split
|
||||
Generates random amounts that sum exactly to the total:
|
||||
|
||||
```csharp
|
||||
private List<decimal> SplitRandomly(decimal totalAmount, int recipientCount)
|
||||
{
|
||||
var random = new Random();
|
||||
var amounts = new List<decimal>();
|
||||
decimal remaining = totalAmount;
|
||||
|
||||
for (int i = 0; i < recipientCount - 1; i++)
|
||||
{
|
||||
var maxAmount = remaining - (recipientCount - i - 1) * 0.01m;
|
||||
var minAmount = 0.01m;
|
||||
var amount = Math.Round((decimal)random.NextDouble() * (maxAmount - minAmount) + minAmount, 2);
|
||||
amounts.Add(amount);
|
||||
remaining -= amount;
|
||||
}
|
||||
|
||||
amounts.Add(Math.Round(remaining, 2)); // Last recipient gets remainder
|
||||
return amounts;
|
||||
}
|
||||
```
|
||||
|
||||
**Example:** 100.00 split randomly among 3 recipients = [45.67, 23.45, 30.88]
|
||||
|
||||
## Expiration and Refunds
|
||||
|
||||
### Automatic Processing
|
||||
Funds are processed hourly by the `FundExpirationJob`:
|
||||
|
||||
```csharp
|
||||
public async Task ProcessExpiredFundsAsync()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var expiredFunds = await db.WalletFunds
|
||||
.Include(f => f.Recipients)
|
||||
.Where(f => f.Status == FundStatus.Created || f.Status == FundStatus.PartiallyReceived)
|
||||
.Where(f => f.ExpiredAt < now)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var fund in expiredFunds)
|
||||
{
|
||||
var unclaimedAmount = fund.Recipients
|
||||
.Where(r => !r.IsReceived)
|
||||
.Sum(r => r.Amount);
|
||||
|
||||
if (unclaimedAmount > 0)
|
||||
{
|
||||
// Refund to creator
|
||||
var creatorWallet = await wat.GetWalletAsync(fund.CreatorAccountId);
|
||||
if (creatorWallet != null)
|
||||
{
|
||||
await CreateTransactionAsync(
|
||||
payerWalletId: null,
|
||||
payeeWalletId: creatorWallet.Id,
|
||||
currency: fund.Currency,
|
||||
amount: unclaimedAmount,
|
||||
remarks: $"Refund for expired fund {fund.Id}",
|
||||
type: TransactionType.System,
|
||||
silent: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fund.Status = FundStatus.Expired;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### Expiration Rules
|
||||
- Default expiration: 24 hours from creation
|
||||
- Custom expiration can be set when creating the fund
|
||||
- Only funds with status `Created` or `PartiallyReceived` are processed
|
||||
- Unclaimed amounts are refunded to the creator
|
||||
- Fund status changes to `Expired`
|
||||
|
||||
## Security & Validation
|
||||
|
||||
### Creation Validation
|
||||
- Creator must have sufficient funds
|
||||
- All recipient accounts must exist and have wallets
|
||||
- At least one recipient required
|
||||
- Total amount must be positive
|
||||
- Creator cannot be a recipient (self-transfer not allowed)
|
||||
|
||||
### Claim Validation
|
||||
- Fund must exist and not be expired/refunded
|
||||
- Recipient must be in the recipient list
|
||||
- Recipient can only claim once
|
||||
- Recipient must have a valid wallet
|
||||
|
||||
### Error Handling
|
||||
- `ArgumentException`: Invalid parameters
|
||||
- `InvalidOperationException`: Business logic violations
|
||||
- All errors provide descriptive messages
|
||||
|
||||
## Database Schema
|
||||
|
||||
### wallet_funds
|
||||
```sql
|
||||
CREATE TABLE wallet_funds (
|
||||
id UUID PRIMARY KEY,
|
||||
currency VARCHAR(128) NOT NULL,
|
||||
total_amount DECIMAL NOT NULL,
|
||||
split_type INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
message TEXT,
|
||||
creator_account_id UUID NOT NULL,
|
||||
expired_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
### wallet_fund_recipients
|
||||
```sql
|
||||
CREATE TABLE wallet_fund_recipients (
|
||||
id UUID PRIMARY KEY,
|
||||
fund_id UUID NOT NULL REFERENCES wallet_funds(id),
|
||||
recipient_account_id UUID NOT NULL,
|
||||
amount DECIMAL NOT NULL,
|
||||
is_received BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
received_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Wallet System
|
||||
- Funds are deducted from creator's wallet pocket immediately upon creation
|
||||
- Individual claims credit recipient's wallet pocket
|
||||
- Refunds credit creator's wallet pocket
|
||||
- All operations create audit transactions
|
||||
|
||||
### Notification System
|
||||
- Integrates with existing push notification system
|
||||
- Notifications sent for fund creation and claims
|
||||
- Uses localized messages for different languages
|
||||
|
||||
### Scheduled Jobs
|
||||
- `FundExpirationJob` runs every hour
|
||||
- Processes expired funds automatically
|
||||
- Handles refunds and status updates
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Red Packet for Group Event
|
||||
```csharp
|
||||
// Create a red packet for 5 friends totaling 500 points
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
creatorId,
|
||||
friendIds, // List of 5 friend account IDs
|
||||
"points",
|
||||
500.00m,
|
||||
FundSplitType.Random, // Lucky draw
|
||||
"Happy Birthday! 🎉"
|
||||
);
|
||||
```
|
||||
|
||||
### Equal Split Bonus Distribution
|
||||
```csharp
|
||||
// Distribute bonus equally among team members
|
||||
var fund = await paymentService.CreateFundAsync(
|
||||
managerId,
|
||||
teamMemberIds,
|
||||
"golds",
|
||||
1000.00m,
|
||||
FundSplitType.Even,
|
||||
"Monthly performance bonus"
|
||||
);
|
||||
```
|
||||
|
||||
### Claiming a Fund
|
||||
```csharp
|
||||
// User claims their portion
|
||||
try
|
||||
{
|
||||
var transaction = await paymentService.ReceiveFundAsync(userId, fundId);
|
||||
// Success - funds credited to user's wallet
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// Handle error (already claimed, expired, not recipient, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Key Metrics
|
||||
- Total funds created per period
|
||||
- Claim rate (claimed vs expired)
|
||||
- Average expiration time
|
||||
- Popular split types
|
||||
|
||||
### Cleanup
|
||||
- Soft-deleted records are cleaned up by `AppDatabaseRecyclingJob`
|
||||
- Expired funds are processed by `FundExpirationJob`
|
||||
- No manual intervention required for normal operation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Fund Templates**: Pre-configured fund types
|
||||
- **Recurring Funds**: Scheduled fund distributions
|
||||
- **Fund Analytics**: Detailed usage statistics
|
||||
- **Fund Categories**: Tagging and categorization
|
||||
- **Bulk Operations**: Create funds for multiple groups
|
||||
- **Fund Forwarding**: Allow recipients to forward unclaimed portions
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user