rename to core
This commit is contained in:
8
core/social/application/dto/CurrentUserSocialDTO.ts
Normal file
8
core/social/application/dto/CurrentUserSocialDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface CurrentUserSocialDTO {
|
||||
driverId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
countryCode: string;
|
||||
primaryTeamId?: string;
|
||||
primaryLeagueId?: string;
|
||||
}
|
||||
22
core/social/application/dto/FeedItemDTO.ts
Normal file
22
core/social/application/dto/FeedItemDTO.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type FeedItemType =
|
||||
| 'race_result'
|
||||
| 'championship_standing'
|
||||
| 'league_announcement'
|
||||
| 'friend_joined_league'
|
||||
| 'friend_won_race';
|
||||
|
||||
export interface FeedItemDTO {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: FeedItemType;
|
||||
actorFriendId?: string;
|
||||
actorDriverId?: string;
|
||||
leagueId?: string;
|
||||
raceId?: string;
|
||||
teamId?: string;
|
||||
position?: number;
|
||||
headline: string;
|
||||
body?: string;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
9
core/social/application/dto/FriendDTO.ts
Normal file
9
core/social/application/dto/FriendDTO.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface FriendDTO {
|
||||
driverId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
isOnline: boolean;
|
||||
lastSeen: Date;
|
||||
primaryLeagueId?: string;
|
||||
primaryTeamId?: string;
|
||||
}
|
||||
16
core/social/application/index.ts
Normal file
16
core/social/application/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { GetCurrentUserSocialUseCase } from './use-cases/GetCurrentUserSocialUseCase';
|
||||
export type { GetCurrentUserSocialParams } from './use-cases/GetCurrentUserSocialUseCase';
|
||||
|
||||
export { GetUserFeedUseCase } from './use-cases/GetUserFeedUseCase';
|
||||
export type { GetUserFeedParams } from './use-cases/GetUserFeedUseCase';
|
||||
|
||||
export type { CurrentUserSocialDTO } from './dto/CurrentUserSocialDTO';
|
||||
export type { FriendDTO } from './dto/FriendDTO';
|
||||
export type { FeedItemDTO } from './dto/FeedItemDTO';
|
||||
|
||||
export type {
|
||||
CurrentUserSocialViewModel,
|
||||
ICurrentUserSocialPresenter,
|
||||
UserFeedViewModel,
|
||||
IUserFeedPresenter,
|
||||
} from './presenters/ISocialPresenters';
|
||||
20
core/social/application/presenters/ISocialPresenters.ts
Normal file
20
core/social/application/presenters/ISocialPresenters.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
|
||||
import type { FriendDTO } from '../dto/FriendDTO';
|
||||
import type { FeedItemDTO } from '../dto/FeedItemDTO';
|
||||
|
||||
export interface CurrentUserSocialViewModel {
|
||||
currentUser: CurrentUserSocialDTO;
|
||||
friends: FriendDTO[];
|
||||
}
|
||||
|
||||
export interface ICurrentUserSocialPresenter {
|
||||
present(viewModel: CurrentUserSocialViewModel): void;
|
||||
}
|
||||
|
||||
export interface UserFeedViewModel {
|
||||
items: FeedItemDTO[];
|
||||
}
|
||||
|
||||
export interface IUserFeedPresenter {
|
||||
present(viewModel: UserFeedViewModel): void;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import type { ISocialGraphRepository } from '../../domain/repositories/ISocialGraphRepository';
|
||||
import type { CurrentUserSocialDTO } from '../dto/CurrentUserSocialDTO';
|
||||
import type { FriendDTO } from '../dto/FriendDTO';
|
||||
import type {
|
||||
CurrentUserSocialViewModel,
|
||||
ICurrentUserSocialPresenter,
|
||||
} from '../presenters/ISocialPresenters';
|
||||
|
||||
export interface GetCurrentUserSocialParams {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application-level use case to retrieve the current user's social context.
|
||||
*
|
||||
* Keeps orchestration in the social bounded context while delegating
|
||||
* data access to domain repositories and presenting via a presenter.
|
||||
*/
|
||||
export class GetCurrentUserSocialUseCase
|
||||
implements AsyncUseCase<GetCurrentUserSocialParams, void> {
|
||||
constructor(
|
||||
private readonly socialGraphRepository: ISocialGraphRepository,
|
||||
public readonly presenter: ICurrentUserSocialPresenter,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(params: GetCurrentUserSocialParams): Promise<void> {
|
||||
this.logger.debug('GetCurrentUserSocialUseCase: Starting execution', { params });
|
||||
try {
|
||||
const { driverId } = params;
|
||||
|
||||
this.logger.debug(`GetCurrentUserSocialUseCase: Fetching friends for driverId: ${driverId}`);
|
||||
const friendsDomain = await this.socialGraphRepository.getFriends(driverId);
|
||||
this.logger.debug('GetCurrentUserSocialUseCase: Successfully fetched friends from social graph repository', { friendsCount: friendsDomain.length });
|
||||
if (friendsDomain.length === 0) {
|
||||
this.logger.warn(`GetCurrentUserSocialUseCase: No friends found for driverId: ${driverId}`);
|
||||
}
|
||||
|
||||
const friends: FriendDTO[] = friendsDomain.map((friend) => ({
|
||||
driverId: friend.id,
|
||||
displayName: friend.name,
|
||||
avatarUrl: '',
|
||||
isOnline: false,
|
||||
lastSeen: new Date(),
|
||||
}));
|
||||
|
||||
const currentUser: CurrentUserSocialDTO = {
|
||||
driverId,
|
||||
displayName: '',
|
||||
avatarUrl: '',
|
||||
countryCode: '',
|
||||
};
|
||||
|
||||
const viewModel: CurrentUserSocialViewModel = {
|
||||
currentUser,
|
||||
friends,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
this.logger.info('GetCurrentUserSocialUseCase: Successfully presented current user social data');
|
||||
} catch (error) {
|
||||
this.logger.error('GetCurrentUserSocialUseCase: Error during execution', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
core/social/application/use-cases/GetUserFeedUseCase.ts
Normal file
72
core/social/application/use-cases/GetUserFeedUseCase.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { IFeedRepository } from '../../domain/repositories/IFeedRepository';
|
||||
import type { FeedItemDTO } from '../dto/FeedItemDTO';
|
||||
import type { FeedItem } from '../../domain/types/FeedItem';
|
||||
import type {
|
||||
IUserFeedPresenter,
|
||||
UserFeedViewModel,
|
||||
} from '../presenters/ISocialPresenters';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
export interface GetUserFeedParams {
|
||||
driverId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class GetUserFeedUseCase
|
||||
implements AsyncUseCase<GetUserFeedParams, void> {
|
||||
constructor(
|
||||
private readonly feedRepository: IFeedRepository,
|
||||
public readonly presenter: IUserFeedPresenter,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(params: GetUserFeedParams): Promise<void> {
|
||||
const { driverId, limit } = params;
|
||||
this.logger.debug('Executing GetUserFeedUseCase', { driverId, limit });
|
||||
|
||||
try {
|
||||
const items = await this.feedRepository.getFeedForDriver(driverId, limit);
|
||||
this.logger.info('Successfully retrieved user feed', { driverId, itemCount: items.length });
|
||||
if (items.length === 0) {
|
||||
this.logger.warn(`No feed items found for driverId: ${driverId}`);
|
||||
}
|
||||
const dtoItems = items.map(mapFeedItemToDTO);
|
||||
|
||||
const viewModel: UserFeedViewModel = {
|
||||
items: dtoItems,
|
||||
};
|
||||
|
||||
this.presenter.present(viewModel);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to retrieve user feed', error);
|
||||
throw error; // Re-throw the error so it can be handled upstream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapFeedItemToDTO(item: FeedItem): FeedItemDTO {
|
||||
const mappedType = (item.type as string).replace(/-/g, '_') as FeedItemDTO['type'];
|
||||
|
||||
const dto: FeedItemDTO = {
|
||||
id: item.id,
|
||||
timestamp:
|
||||
item.timestamp instanceof Date
|
||||
? item.timestamp.toISOString()
|
||||
: new Date(item.timestamp).toISOString(),
|
||||
type: mappedType,
|
||||
headline: item.headline,
|
||||
};
|
||||
|
||||
if (item.actorFriendId !== undefined) dto.actorFriendId = item.actorFriendId;
|
||||
if (item.actorDriverId !== undefined) dto.actorDriverId = item.actorDriverId;
|
||||
if (item.leagueId !== undefined) dto.leagueId = item.leagueId;
|
||||
if (item.raceId !== undefined) dto.raceId = item.raceId;
|
||||
if (item.teamId !== undefined) dto.teamId = item.teamId;
|
||||
if (item.position !== undefined) dto.position = item.position;
|
||||
if (item.body !== undefined) dto.body = item.body;
|
||||
if (item.ctaLabel !== undefined) dto.ctaLabel = item.ctaLabel;
|
||||
if (item.ctaHref !== undefined) dto.ctaHref = item.ctaHref;
|
||||
|
||||
return dto;
|
||||
}
|
||||
19
core/social/domain/errors/SocialDomainError.ts
Normal file
19
core/social/domain/errors/SocialDomainError.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
/**
|
||||
* Domain Error: SocialDomainError
|
||||
*
|
||||
* Implements the shared IDomainError contract for social domain failures.
|
||||
*/
|
||||
export class SocialDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
|
||||
readonly name = 'SocialDomainError';
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'social';
|
||||
readonly kind: CommonDomainErrorKind;
|
||||
|
||||
constructor(message: string, kind: CommonDomainErrorKind = 'validation') {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
6
core/social/domain/repositories/IFeedRepository.ts
Normal file
6
core/social/domain/repositories/IFeedRepository.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { FeedItem } from '../types/FeedItem';
|
||||
|
||||
export interface IFeedRepository {
|
||||
getFeedForDriver(driverId: string, limit?: number): Promise<FeedItem[]>;
|
||||
getGlobalFeed(limit?: number): Promise<FeedItem[]>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
|
||||
export interface ISocialGraphRepository {
|
||||
getFriends(driverId: string): Promise<Driver[]>;
|
||||
getFriendIds(driverId: string): Promise<string[]>;
|
||||
getSuggestedFriends(driverId: string, limit?: number): Promise<Driver[]>;
|
||||
}
|
||||
23
core/social/domain/types/FeedItem.ts
Normal file
23
core/social/domain/types/FeedItem.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FeedItemType } from './FeedItemType';
|
||||
|
||||
/**
|
||||
* Domain Type: FeedItem
|
||||
*
|
||||
* Pure feed item shape used by repositories and application DTO mappers.
|
||||
* This is not a domain entity (no identity/behavior beyond data).
|
||||
*/
|
||||
export interface FeedItem {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
type: FeedItemType;
|
||||
actorFriendId?: string;
|
||||
actorDriverId?: string;
|
||||
leagueId?: string;
|
||||
raceId?: string;
|
||||
teamId?: string;
|
||||
position?: number;
|
||||
headline: string;
|
||||
body?: string;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
}
|
||||
15
core/social/domain/types/FeedItemType.ts
Normal file
15
core/social/domain/types/FeedItemType.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Domain Type: FeedItemType
|
||||
*
|
||||
* Union representing the kinds of items that can appear in a user's social feed.
|
||||
* This is a pure type and therefore belongs in domain/types rather than
|
||||
* domain/value-objects, which is reserved for class-based value objects.
|
||||
*/
|
||||
export type FeedItemType =
|
||||
| 'friend-joined-league'
|
||||
| 'friend-joined-team'
|
||||
| 'friend-finished-race'
|
||||
| 'friend-new-personal-best'
|
||||
| 'new-race-scheduled'
|
||||
| 'new-result-posted'
|
||||
| 'league-highlight';
|
||||
148
core/social/infrastructure/inmemory/InMemorySocialAndFeed.ts
Normal file
148
core/social/infrastructure/inmemory/InMemorySocialAndFeed.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/types/FeedItem';
|
||||
import type { IFeedRepository } from '@gridpilot/social/domain/repositories/IFeedRepository';
|
||||
import type { ISocialGraphRepository } from '@gridpilot/social/domain/repositories/ISocialGraphRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export type Friendship = {
|
||||
driverId: string;
|
||||
friendId: string;
|
||||
};
|
||||
|
||||
export type RacingSeedData = {
|
||||
drivers: Driver[];
|
||||
friendships: Friendship[];
|
||||
feedEvents: FeedItem[];
|
||||
};
|
||||
|
||||
export class InMemoryFeedRepository implements IFeedRepository {
|
||||
private readonly feedEvents: FeedItem[];
|
||||
private readonly friendships: Friendship[];
|
||||
private readonly driversById: Map<string, Driver>;
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, seed: RacingSeedData) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryFeedRepository initialized.');
|
||||
this.feedEvents = seed.feedEvents;
|
||||
this.friendships = seed.friendships;
|
||||
this.driversById = new Map(seed.drivers.map((d) => [d.id, d]));
|
||||
}
|
||||
|
||||
async getFeedForDriver(driverId: string, limit?: number): Promise<FeedItem[]> {
|
||||
this.logger.debug(`Getting feed for driver: ${driverId}, limit: ${limit}`);
|
||||
try {
|
||||
const friendIds = new Set(
|
||||
this.friendships
|
||||
.filter((f) => f.driverId === driverId)
|
||||
.map((f) => f.friendId),
|
||||
);
|
||||
|
||||
const items = this.feedEvents.filter((item) => {
|
||||
if (item.actorDriverId && friendIds.has(item.actorDriverId)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const sorted = items
|
||||
.slice()
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
this.logger.info(`Found ${sorted.length} feed items for driver: ${driverId}.`);
|
||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting feed for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGlobalFeed(limit?: number): Promise<FeedItem[]> {
|
||||
this.logger.debug(`Getting global feed, limit: ${limit}`);
|
||||
try {
|
||||
const sorted = this.feedEvents
|
||||
.slice()
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
this.logger.info(`Found ${sorted.length} global feed items.`);
|
||||
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting global feed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemorySocialGraphRepository implements ISocialGraphRepository {
|
||||
private readonly friendships: Friendship[];
|
||||
private readonly driversById: Map<string, Driver>;
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, seed: RacingSeedData) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemorySocialGraphRepository initialized.');
|
||||
this.friendships = seed.friendships;
|
||||
this.driversById = new Map(seed.drivers.map((d) => [d.id, d]));
|
||||
}
|
||||
|
||||
async getFriendIds(driverId: string): Promise<string[]> {
|
||||
this.logger.debug(`Getting friend IDs for driver: ${driverId}`);
|
||||
try {
|
||||
const friendIds = this.friendships
|
||||
.filter((f) => f.driverId === driverId)
|
||||
.map((f) => f.friendId);
|
||||
this.logger.info(`Found ${friendIds.length} friend IDs for driver: ${driverId}.`);
|
||||
return friendIds;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting friend IDs for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getFriends(driverId: string): Promise<Driver[]> {
|
||||
this.logger.debug(`Getting friends for driver: ${driverId}`);
|
||||
try {
|
||||
const ids = await this.getFriendIds(driverId);
|
||||
const friends = ids
|
||||
.map((id) => this.driversById.get(id))
|
||||
.filter((d): d is Driver => Boolean(d));
|
||||
this.logger.info(`Found ${friends.length} friends for driver: ${driverId}.`);
|
||||
return friends;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting friends for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuggestedFriends(driverId: string, limit?: number): Promise<Driver[]> {
|
||||
this.logger.debug(`Getting suggested friends for driver: ${driverId}, limit: ${limit}`);
|
||||
try {
|
||||
const directFriendIds = new Set(await this.getFriendIds(driverId));
|
||||
const suggestions = new Map<string, number>();
|
||||
|
||||
for (const friendship of this.friendships) {
|
||||
if (!directFriendIds.has(friendship.driverId)) continue;
|
||||
const friendOfFriendId = friendship.friendId;
|
||||
if (friendOfFriendId === driverId) continue;
|
||||
if (directFriendIds.has(friendOfFriendId)) continue;
|
||||
|
||||
suggestions.set(friendOfFriendId, (suggestions.get(friendOfFriendId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const rankedIds = Array.from(suggestions.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([id]) => id);
|
||||
|
||||
const drivers = rankedIds
|
||||
.map((id) => this.driversById.get(id))
|
||||
.filter((d): d is Driver => Boolean(d));
|
||||
|
||||
const result = typeof limit === 'number' ? drivers.slice(0, limit) : drivers;
|
||||
this.logger.info(`Found ${result.length} suggested friends for driver: ${driverId}.`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting suggested friends for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
10
core/social/package.json
Normal file
10
core/social/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@gridpilot/social",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
}
|
||||
}
|
||||
11
core/social/tsconfig.json
Normal file
11
core/social/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user