Compare commits

..

48 Commits

Author SHA1 Message Date
46ebd92dc1 ♻️ Refactored the chat mention logic 2025-10-17 00:46:55 +08:00
7f8521bb40 👔 Optimize subscriptions logic 2025-10-16 13:13:08 +08:00
f01226d91a 🐛 Fix post controller return incomplete structure 2025-10-13 23:11:35 +08:00
6cb6dee6be 🐛 Remove project Sphere dict key snake case convert to fix reaction counts 2025-10-13 01:19:51 +08:00
0e9caf67ff 🐛 username color hotfix 2025-10-13 01:16:35 +08:00
ca70bb5487 🐛 Fix missing username color in proto profile 2025-10-13 01:08:48 +08:00
59ed135f20 Load account info in reaction list API 2025-10-12 21:57:37 +08:00
6077f91529 Sticker search 2025-10-12 21:46:45 +08:00
5c485bb1c3 🐛 Fix autocomplete again 2025-10-12 19:30:46 +08:00
27d979d77b 🐛 Fix sticker auto complete 2025-10-12 19:21:00 +08:00
15687a0c32 Standalone auto complete 2025-10-12 16:59:26 +08:00
37ea882ef7 Full featured auto complete 2025-10-12 16:55:32 +08:00
e624c2bb3e ⬆️ Upgrade aspire 2025-10-12 16:06:39 +08:00
9631cd3edd Auto completion in chat 2025-10-12 16:00:32 +08:00
f4a659fce5 🐛 Fix DM room member loading issue 2025-10-12 15:46:45 +08:00
1ded811b36 Publisher heatmap 2025-10-12 15:32:49 +08:00
32977d9580 🐛 Fix post controller does not contains publisher in success created response 2025-10-11 23:55:00 +08:00
aaf29e7228 🐛 Fix gateway user ip detection 2025-10-09 22:50:26 +08:00
658ef3bddf 🐛 Fix gateway IP detection issue 2025-10-09 00:10:32 +08:00
fc0bc936ce New version of sticker rendering support 2025-10-08 21:28:48 +08:00
3850ae6a8e 🔊 Rate limiting logs 2025-10-08 18:07:19 +08:00
21c99567b4 🐛 Fix wrong method to configure rate limiting 2025-10-08 18:05:59 +08:00
1315c7f4d4 🐛 Fix rate limiter 2025-10-08 18:01:25 +08:00
630a532d98 🐛 Fix app host 2025-10-08 18:01:21 +08:00
b9bb180113 Username color 2025-10-08 13:11:30 +08:00
04d74d0d70 Trying to optimize the scheduled jobs 2025-10-08 12:59:54 +08:00
6a8a0ed491 👔 Limit custom reactions 2025-10-08 02:46:56 +08:00
0f835845bf ♻️ Merge the ServiceDefault and Shared project 2025-10-07 19:44:52 +08:00
c5d8a8d07f 🔇 Mute ungraceful closed websocket 2025-10-07 17:54:58 +08:00
95e2ba1136 🐛 Fixes some issues in drive service 2025-10-07 01:07:24 +08:00
1176fde8b4 🐛 Fix health check 2025-10-07 00:41:26 +08:00
e634968e00 🐛 Brings health check back to live 2025-10-07 00:34:00 +08:00
282a1dbddc 🐛 Fix didn't expose X-Total 2025-10-06 23:40:44 +08:00
c64adace24 💄 Using remote site instead of embed frontend (removed) to handle oidc redirect 2025-10-06 13:05:50 +08:00
8ac0b28c66 🚚 Move callback to under api 2025-10-06 13:01:15 +08:00
8f71d7f9e5 🐛 Fix some bugs 2025-10-06 12:46:25 +08:00
c435e63917 Able to update the custom apps order's status 2025-10-05 22:20:32 +08:00
243159e4cc Custom apps create payment orders 2025-10-05 21:59:07 +08:00
42dad7095a 💄 Optimize the transfer 2025-10-05 16:17:57 +08:00
d1efcdede8 Transfer fee and pin validate 2025-10-05 15:52:54 +08:00
47680475b3 🐛 Fix develop service 2025-10-05 00:09:21 +08:00
6632d43f32 🐛 Trying to fix develop 2025-10-05 00:05:37 +08:00
29c4dcd71c Wallet stats 2025-10-05 00:05:31 +08:00
e7aa887715 🐛 Fix wrong signing algo 2025-10-04 19:55:27 +08:00
0f05633996 🐛 Fix oidc didn't provides with authorized party 2025-10-04 19:03:57 +08:00
966af08a33 Wallet stats 2025-10-04 15:38:58 +08:00
b25b90a074 Wallet funds 2025-10-04 01:17:21 +08:00
dcbefeaaab 👔 Purchase gift requires minimal level 2025-10-03 17:20:58 +08:00
69 changed files with 10342 additions and 610 deletions

613
API_WALLET_FUNDS.md Normal file
View 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

View File

@@ -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();

View File

@@ -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>

View File

@@ -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"
}
}
}
}
}

View File

@@ -31,7 +31,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -68,7 +68,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -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));
}
}
}

View File

@@ -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)

View File

@@ -12,7 +12,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -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();

View File

@@ -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);
}
}
}

View File

@@ -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)
{

View File

@@ -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!;

View File

@@ -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" },

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -49,7 +49,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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
}

View File

@@ -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);

View 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");
}
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -250,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,
@@ -684,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.");
@@ -696,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)
@@ -710,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,
@@ -719,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",
@@ -730,7 +791,6 @@ public class SubscriptionService(
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
GiftId = gift.Id
};
// Update the gift status
@@ -741,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;
}

View File

@@ -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);
}
}
}

View File

@@ -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(
}
}
}
}
}

View File

@@ -42,7 +42,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
</ItemGroup>

View File

@@ -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();
}

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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()
};

View 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; }
}

View 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!;
}

View File

@@ -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;
}
}

View File

@@ -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; }

View File

@@ -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;
@@ -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]

View File

@@ -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(),
};
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;

View File

@@ -122,7 +122,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.ServiceDefaults\DysonNetwork.ServiceDefaults.csproj" />
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj"/>
</ItemGroup>

View 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();
}
}
}

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -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;
})];
}
}
}

View File

@@ -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 * * ?")
);

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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
View 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