Compare commits
50 Commits
d17c26a228
...
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
|
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
|
@@ -7,25 +7,17 @@ var isDev = builder.Environment.IsDevelopment();
|
||||
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);
|
||||
@@ -38,6 +30,9 @@ List<IResourceBuilder<ProjectResource>> services =
|
||||
for (var idx = 0; idx < services.Count; idx++)
|
||||
{
|
||||
var service = services[idx];
|
||||
|
||||
service.WithReference(cache).WithReference(queue);
|
||||
|
||||
var grpcPort = 7002 + idx;
|
||||
|
||||
if (isDev)
|
||||
@@ -60,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.5.0"/>
|
||||
|
||||
<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.0" />
|
||||
<PackageReference Include="Aspire.Hosting.Docker" Version="9.4.2-preview.1.25428.12" />
|
||||
<PackageReference Include="Aspire.Hosting.Nats" Version="9.5.0" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.0" />
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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" };
|
||||
@@ -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}" } }
|
||||
@@ -114,6 +152,14 @@ 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();
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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,6 +43,8 @@ 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!;
|
||||
|
@@ -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>
|
||||
|
||||
|
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");
|
||||
}
|
||||
}
|
||||
}
|
@@ -478,6 +478,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<UsernameColor>("UsernameColor")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("username_color");
|
||||
|
||||
b.Property<SnVerificationMark>("Verification")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("verification");
|
||||
@@ -1387,6 +1391,116 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.ToTable("wallet_coupons", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("CreatorAccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("creator_account_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<int>("SplitType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("split_type");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<decimal>("TotalAmount")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("total_amount");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_funds");
|
||||
|
||||
b.HasIndex("CreatorAccountId")
|
||||
.HasDatabaseName("ix_wallet_funds_creator_account_id");
|
||||
|
||||
b.ToTable("wallet_funds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Guid>("FundId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("fund_id");
|
||||
|
||||
b.Property<bool>("IsReceived")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_received");
|
||||
|
||||
b.Property<Instant?>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<Guid>("RecipientAccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("recipient_account_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_fund_recipients");
|
||||
|
||||
b.HasIndex("FundId")
|
||||
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
|
||||
|
||||
b.HasIndex("RecipientAccountId")
|
||||
.HasDatabaseName("ix_wallet_fund_recipients_recipient_account_id");
|
||||
|
||||
b.ToTable("wallet_fund_recipients", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1464,6 +1578,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid?>("SubscriptionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("subscription_id");
|
||||
|
||||
b.Property<string>("SubscriptionIdentifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
@@ -1492,6 +1610,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("RedeemerId")
|
||||
.HasDatabaseName("ix_wallet_gifts_redeemer_id");
|
||||
|
||||
b.HasIndex("SubscriptionId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallet_gifts_subscription_id");
|
||||
|
||||
b.ToTable("wallet_gifts", (string)null);
|
||||
});
|
||||
|
||||
@@ -1648,10 +1770,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<Guid?>("GiftId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("gift_id");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
@@ -1698,10 +1816,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("CouponId")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
|
||||
|
||||
b.HasIndex("GiftId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_wallet_subscriptions_gift_id");
|
||||
|
||||
b.HasIndex("Identifier")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_identifier");
|
||||
|
||||
@@ -2055,6 +2169,39 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "CreatorAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("CreatorAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_funds_accounts_creator_account_id");
|
||||
|
||||
b.Navigation("CreatorAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
|
||||
.WithMany("Recipients")
|
||||
.HasForeignKey("FundId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "RecipientAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("RecipientAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_fund_recipients_accounts_recipient_account_id");
|
||||
|
||||
b.Navigation("Fund");
|
||||
|
||||
b.Navigation("RecipientAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
|
||||
@@ -2079,6 +2226,11 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasForeignKey("RedeemerId")
|
||||
.HasConstraintName("fk_wallet_gifts_accounts_redeemer_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
|
||||
.WithOne("Gift")
|
||||
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
|
||||
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
|
||||
|
||||
b.Navigation("Coupon");
|
||||
|
||||
b.Navigation("Gifter");
|
||||
@@ -2086,6 +2238,8 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Recipient");
|
||||
|
||||
b.Navigation("Redeemer");
|
||||
|
||||
b.Navigation("Subscription");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
|
||||
@@ -2131,16 +2285,9 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasForeignKey("CouponId")
|
||||
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnWalletGift", "Gift")
|
||||
.WithOne("Subscription")
|
||||
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletSubscription", "GiftId")
|
||||
.HasConstraintName("fk_wallet_subscriptions_wallet_gifts_gift_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Coupon");
|
||||
|
||||
b.Navigation("Gift");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
|
||||
@@ -2194,9 +2341,14 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.Navigation("Pockets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
|
||||
{
|
||||
b.Navigation("Subscription");
|
||||
b.Navigation("Recipients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
|
||||
{
|
||||
b.Navigation("Gift");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
@@ -56,6 +56,16 @@ public static class ScheduledJobsConfiguration
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
@@ -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; }
|
||||
}
|
||||
|
@@ -146,19 +146,6 @@ public class SubscriptionGiftController(
|
||||
{
|
||||
error = "You already have an active subscription of this type.";
|
||||
}
|
||||
else if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
|
||||
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
|
||||
{
|
||||
error =
|
||||
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
|
||||
}
|
||||
else
|
||||
{
|
||||
canRedeem = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
canRedeem = true;
|
||||
@@ -197,6 +184,8 @@ public class SubscriptionGiftController(
|
||||
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
|
||||
}
|
||||
|
||||
const int MinimumAccountLevel = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Purchases a gift subscription.
|
||||
/// </summary>
|
||||
@@ -206,6 +195,12 @@ public class SubscriptionGiftController(
|
||||
{
|
||||
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);
|
||||
|
@@ -79,15 +79,6 @@ public class SubscriptionService(
|
||||
var couponData = await couponTask;
|
||||
|
||||
// Validation checks
|
||||
if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
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}."
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreeTrial && prevFreeTrial != null)
|
||||
throw new InvalidOperationException("Free trial already exists.");
|
||||
@@ -259,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,
|
||||
@@ -693,6 +692,9 @@ public class SubscriptionService(
|
||||
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.");
|
||||
@@ -705,6 +707,56 @@ public class SubscriptionService(
|
||||
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)
|
||||
@@ -719,7 +771,7 @@ public class SubscriptionService(
|
||||
// We do not check account level requirement, since it is a gift
|
||||
|
||||
// Create the subscription from the gift
|
||||
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription
|
||||
var cycleDuration = Duration.FromDays(28);
|
||||
var subscription = new SnWalletSubscription
|
||||
{
|
||||
BegunAt = now,
|
||||
@@ -728,7 +780,7 @@ public class SubscriptionService(
|
||||
IsActive = true,
|
||||
IsFreeTrial = false,
|
||||
Status = Shared.Models.SubscriptionStatus.Active,
|
||||
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption
|
||||
PaymentMethod = "gift", // Special payment method indicating gift redemption
|
||||
PaymentDetails = new Shared.Models.SnPaymentDetails
|
||||
{
|
||||
Currency = "gift",
|
||||
@@ -739,7 +791,6 @@ public class SubscriptionService(
|
||||
Coupon = gift.Coupon,
|
||||
RenewalAt = now.Plus(cycleDuration),
|
||||
AccountId = redeemer.Id,
|
||||
GiftId = gift.Id
|
||||
};
|
||||
|
||||
// Update the gift status
|
||||
@@ -750,18 +801,18 @@ public class SubscriptionService(
|
||||
gift.UpdatedAt = now;
|
||||
|
||||
// Save both gift and subscription
|
||||
using var transaction = await db.Database.BeginTransactionAsync();
|
||||
using var createTransaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
db.WalletSubscriptions.Add(subscription);
|
||||
db.WalletGifts.Update(gift);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
await createTransaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
await createTransaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -84,6 +84,15 @@ public class WebSocketController(
|
||||
{
|
||||
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,
|
||||
@@ -152,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,10 +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.Net;
|
||||
|
||||
namespace DysonNetwork.Ring.Services;
|
||||
|
||||
@@ -37,7 +35,7 @@ public class QueueBackgroundService(
|
||||
private async Task RunConsumerAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("Queue consumer started");
|
||||
|
||||
|
||||
await foreach (var msg in nats.SubscribeAsync<byte[]>(QueueName, queueGroup: QueueGroup, cancellationToken: stoppingToken))
|
||||
{
|
||||
try
|
||||
@@ -105,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)
|
||||
{
|
||||
@@ -117,4 +115,4 @@ public class QueueBackgroundService(
|
||||
await pushService.DeliverPushNotification(notification, cancellationToken);
|
||||
logger.LogDebug("Successfully processed push notification for account {AccountId}", notification.AccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
@@ -33,14 +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>
|
||||
<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;
|
||||
}
|
@@ -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!;
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
)
|
||||
};
|
||||
|
||||
@@ -128,7 +129,8 @@ public class SnWalletGift : ModelBase
|
||||
/// <summary>
|
||||
/// The subscription created when the gift is redeemed.
|
||||
/// </summary>
|
||||
public SnWalletSubscription? Subscription { get; set; }
|
||||
[JsonIgnore] public SnWalletSubscription? Subscription { get; set; }
|
||||
public Guid? SubscriptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the gift expires and can no longer be redeemed.
|
||||
@@ -337,7 +339,6 @@ public class SnWalletSubscription : ModelBase
|
||||
/// <summary>
|
||||
/// If this subscription was redeemed from a gift, this references the gift record.
|
||||
/// </summary>
|
||||
public Guid? GiftId { get; set; }
|
||||
public SnWalletGift? Gift { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
|
@@ -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;
|
||||
|
@@ -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();
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
|
||||
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 && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.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 && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.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,28 +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 => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.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);
|
||||
|
||||
@@ -257,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
|
||||
@@ -273,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,
|
||||
@@ -322,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 && m.JoinedAt != null && m.LeaveAt == null);
|
||||
.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);
|
||||
}
|
||||
}
|
@@ -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 && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.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.JoinedAt != null && 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);
|
||||
@@ -156,12 +157,15 @@ 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:";
|
||||
@@ -192,4 +196,4 @@ public class ChatRoomService(
|
||||
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;
|
||||
|
@@ -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
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
|
||||
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;
|
||||
})];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
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
|
Reference in New Issue
Block a user